Compare commits
34
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
977f624123 | ||
|
|
bb7dde1963 | ||
|
|
446380e3a9 | ||
|
|
e07f248cc8 | ||
|
|
90ac6e0825 | ||
|
|
4e3c953acf | ||
|
|
ede183d026 | ||
|
|
481c9dca65 | ||
|
|
4025106234 | ||
|
|
a3134f997f | ||
|
|
3607fe445e | ||
|
|
61d549a9d5 | ||
|
|
2b84bc53e9 | ||
|
|
282dab43eb | ||
|
|
cc9df4004c | ||
|
|
b5d70ac1cd | ||
|
|
9118c16fe3 | ||
|
|
fe67849e57 | ||
|
|
21b303ba7c | ||
|
|
9375f25629 | ||
|
|
905654cc84 | ||
|
|
219fa3c615 | ||
|
|
c4e055ce51 | ||
|
|
10eca353ab | ||
|
|
88fc50afb8 | ||
|
|
19c68d73a7 | ||
|
|
41c41a860e | ||
|
|
4bf59b45b7 | ||
|
|
fc99d9e0d7 | ||
|
|
90f1239fba | ||
|
|
b2fe1cb1e0 | ||
|
|
065a879190 | ||
|
|
89882b4863 | ||
|
|
18a08daa91 |
@@ -139,6 +139,23 @@ jobs:
|
|||||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Nor is a prerelease, and this trigger is `v*`, which matches
|
||||||
|
# `v0.4.0-beta.1`. Two reasons it is worst here. The APK goes
|
||||||
|
# to the *generic* registry, which is readable without
|
||||||
|
# credentials so Obtainium can poll a plain URL — a beta would
|
||||||
|
# be offered to every device on it. And the versionCode maths
|
||||||
|
# below splits on dots and would read "1" out of "0-beta",
|
||||||
|
# producing a code that is wrong rather than a build that
|
||||||
|
# fails: Android orders releases by that integer and refuses
|
||||||
|
# anything not greater than what is installed.
|
||||||
|
case "$v" in
|
||||||
|
*-*)
|
||||||
|
echo "v$v is a prerelease; not publishing an APK for it"
|
||||||
|
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
esac
|
||||||
echo "skip=false" >> "$GITHUB_OUTPUT"
|
echo "skip=false" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
# Android orders releases by an integer and refuses anything
|
# Android orders releases by an integer and refuses anything
|
||||||
|
|||||||
@@ -72,6 +72,22 @@ jobs:
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# A prerelease is not a shipment either, and this trigger is
|
||||||
|
# `v*` — which matches `v0.4.0-beta.1`. Nothing produces one
|
||||||
|
# today; the guard is here because the thing that would is
|
||||||
|
# semantic-release's `prerelease: true` channel, a one-line
|
||||||
|
# change in .releaserc.yml whose blast radius is four public
|
||||||
|
# package channels. Same argument as release.yml's
|
||||||
|
# `chore(release):` guard: cheap, against something a future
|
||||||
|
# edit turns on somewhere else entirely.
|
||||||
|
case "$v" in
|
||||||
|
*-*)
|
||||||
|
echo "$v is a prerelease; not packaging it for pacman"
|
||||||
|
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
echo "skip=false" >> "$GITHUB_OUTPUT"
|
echo "skip=false" >> "$GITHUB_OUTPUT"
|
||||||
echo "tag=$v" >> "$GITHUB_OUTPUT"
|
echo "tag=$v" >> "$GITHUB_OUTPUT"
|
||||||
echo "building $v"
|
echo "building $v"
|
||||||
|
|||||||
@@ -95,6 +95,19 @@ jobs:
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Nor is a prerelease, and this trigger is `v*`, which matches
|
||||||
|
# `v0.4.0-beta.1`. The mildest of the four — assets attach to
|
||||||
|
# the prerelease's own Gitea release and no package manager
|
||||||
|
# reads them — but four workflows sharing one trigger should
|
||||||
|
# share one answer about what a shipment is.
|
||||||
|
case "$v" in
|
||||||
|
*-*)
|
||||||
|
echo "$v is a prerelease; not attaching desktop assets"
|
||||||
|
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
echo "skip=false" >> "$GITHUB_OUTPUT"
|
echo "skip=false" >> "$GITHUB_OUTPUT"
|
||||||
echo "tag=$v" >> "$GITHUB_OUTPUT"
|
echo "tag=$v" >> "$GITHUB_OUTPUT"
|
||||||
echo "version=${v#v}" >> "$GITHUB_OUTPUT"
|
echo "version=${v#v}" >> "$GITHUB_OUTPUT"
|
||||||
|
|||||||
@@ -56,6 +56,18 @@ jobs:
|
|||||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Nor is a prerelease, and this trigger is `v*`, which matches
|
||||||
|
# `v0.4.0-beta.1`. It matters most here of the four: the tap
|
||||||
|
# is public, and `brew upgrade` would offer a beta to everyone
|
||||||
|
# on it.
|
||||||
|
case "$VERSION" in
|
||||||
|
*-*)
|
||||||
|
echo "$TAG is a prerelease; not syncing it to a public tap"
|
||||||
|
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
esac
|
||||||
echo "skip=false" >> "$GITHUB_OUTPUT"
|
echo "skip=false" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
TARBALL="${SOURCE_TARBALL_BASE}/${TAG}.tar.gz"
|
TARBALL="${SOURCE_TARBALL_BASE}/${TAG}.tar.gz"
|
||||||
|
|||||||
@@ -1,11 +1,36 @@
|
|||||||
name: Release
|
name: Release
|
||||||
|
|
||||||
# The sixth workflow, and the one that decides whether the other three
|
# The sixth workflow, and the one that decides whether the other three
|
||||||
# run at all. On every push to main it reads the Conventional Commits
|
# run at all. It reads the Conventional Commits since the last tag, and
|
||||||
# since the last tag, and if any of them is releasable it writes the
|
# if any of them is releasable it writes the changelog, pushes the tag,
|
||||||
# changelog, pushes the tag, and creates the Gitea release whose body is
|
# and creates the Gitea release whose body is that changelog section.
|
||||||
# that changelog section. The publishing workflows are keyed on `v*`, so
|
# The publishing workflows are keyed on `v*`, so the tag push is what
|
||||||
# the tag push is what starts them.
|
# starts them.
|
||||||
|
#
|
||||||
|
# **It is triggered by hand, and there is deliberately no `push`
|
||||||
|
# trigger.** There was one, on `main`, which made the trigger "a PR was
|
||||||
|
# merged" and nothing else: eight releases in twenty-two hours
|
||||||
|
# (v0.0.1 -> v0.3.1) for one session's work, each fanning out to four
|
||||||
|
# publishers on a runner with capacity 1, so ~40 packaging jobs shipped
|
||||||
|
# three issues and ordinary PR CI queued behind them. A version per
|
||||||
|
# merged PR is a version per unit of *work*, not per *shipment*, and
|
||||||
|
# pacman, Homebrew and Obtainium see every one.
|
||||||
|
#
|
||||||
|
# Nothing else had to change to batch them: semantic-release already
|
||||||
|
# reads every commit since the last tag, so five fixes and two feats
|
||||||
|
# become one minor release with all seven in the notes. Release
|
||||||
|
# frequency was only ever how often this file fired.
|
||||||
|
#
|
||||||
|
# This is the rule `index-artifact.yml` states and is the other instance
|
||||||
|
# of: **a job that mutates state which cannot be rebuilt in ten minutes
|
||||||
|
# is triggered deliberately, not by a push.** A release here is a tag,
|
||||||
|
# a Gitea release, an Arch package, a Homebrew formula, a signed APK and
|
||||||
|
# desktop assets — and an Android version going backwards costs the user
|
||||||
|
# their library (docs/android-release.md).
|
||||||
|
#
|
||||||
|
# A schedule was considered and rejected: a cron batches without anyone
|
||||||
|
# having to remember, but it puts the decision back on a timer, which is
|
||||||
|
# the thing being removed.
|
||||||
#
|
#
|
||||||
# **Why the tag is pushed with PACKAGE_TOKEN and not the Actions token.**
|
# **Why the tag is pushed with PACKAGE_TOKEN and not the Actions token.**
|
||||||
# Gitea, like GitHub, does not start a workflow from a ref pushed by a
|
# Gitea, like GitHub, does not start a workflow from a ref pushed by a
|
||||||
@@ -19,9 +44,12 @@ name: Release
|
|||||||
# instead.
|
# instead.
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
dry_run:
|
||||||
|
description: "Report what would be released and stop"
|
||||||
|
required: false
|
||||||
|
default: "false"
|
||||||
|
|
||||||
# Cutting a tag is not a thing to cancel halfway: a superseded run must
|
# Cutting a tag is not a thing to cancel halfway: a superseded run must
|
||||||
# finish, not be killed between `git push --tags` and the release POST.
|
# finish, not be killed between `git push --tags` and the release POST.
|
||||||
@@ -163,14 +191,32 @@ jobs:
|
|||||||
# been right, the tag would have been right, every job would have
|
# been right, the tag would have been right, every job would have
|
||||||
# been green, and the release body would have been empty. Check the
|
# been green, and the release body would have been empty. Check the
|
||||||
# notes, not the exit code, before moving any of these.
|
# notes, not the exit code, before moving any of these.
|
||||||
|
# The point of a manual trigger is deliberateness, and deliberate
|
||||||
|
# means being able to look before pulling the lever. `--dry-run`
|
||||||
|
# reports the version and the notes and writes nothing: no tag, no
|
||||||
|
# release, no publishers. `make release-dry` is the same answer
|
||||||
|
# locally; this is it from the runner, against the same commit and
|
||||||
|
# the same tag history, which is what actually decides.
|
||||||
- name: Run semantic-release
|
- name: Run semantic-release
|
||||||
if: steps.guard.outputs.skip == 'false'
|
if: steps.guard.outputs.skip == 'false'
|
||||||
working-directory: /src
|
working-directory: /src
|
||||||
|
env:
|
||||||
|
DRY_RUN: ${{ inputs.dry_run }}
|
||||||
run: |
|
run: |
|
||||||
set -eu
|
set -eu
|
||||||
git config user.name "yellowjacket-ci"
|
git config user.name "yellowjacket-ci"
|
||||||
git config user.email "yj@yellowjacket.app"
|
git config user.email "yj@yellowjacket.app"
|
||||||
|
|
||||||
|
# Anything but a literal "true" releases for real. A typo in a
|
||||||
|
# dispatch box must not silently turn a shipment into a no-op
|
||||||
|
# that reports success — the failure worth avoiding is the one
|
||||||
|
# where nothing happens and the run is green.
|
||||||
|
dry=""
|
||||||
|
if [ "${DRY_RUN:-false}" = "true" ]; then
|
||||||
|
echo "DRY RUN — no tag will be pushed and no release created"
|
||||||
|
dry="--dry-run"
|
||||||
|
fi
|
||||||
|
|
||||||
npx --yes \
|
npx --yes \
|
||||||
-p semantic-release@25 \
|
-p semantic-release@25 \
|
||||||
-p @semantic-release/commit-analyzer@13 \
|
-p @semantic-release/commit-analyzer@13 \
|
||||||
@@ -178,5 +224,5 @@ jobs:
|
|||||||
-p @semantic-release/changelog@7 \
|
-p @semantic-release/changelog@7 \
|
||||||
-p @semantic-release/exec@7 \
|
-p @semantic-release/exec@7 \
|
||||||
-p conventional-changelog-conventionalcommits@9 \
|
-p conventional-changelog-conventionalcommits@9 \
|
||||||
semantic-release \
|
semantic-release $dry \
|
||||||
--repository-url "https://x-access-token:${PACKAGE_TOKEN}@${SERVER_URL#https://}/${REPO}.git"
|
--repository-url "https://x-access-token:${PACKAGE_TOKEN}@${SERVER_URL#https://}/${REPO}.git"
|
||||||
|
|||||||
@@ -3534,3 +3534,133 @@ silent.** `artifactHasTotals` and `artifactHasCredits` are both correct
|
|||||||
and both mean a feature can ship, pass every test, and produce nothing
|
and both mean a feature can ship, pass every test, and produce nothing
|
||||||
for anybody without a single failure anywhere. Checking the *published
|
for anybody without a single failure anywhere. Checking the *published
|
||||||
file* is one query and is not implied by any tick in CI.
|
file* is one query and is not implied by any tick in CI.
|
||||||
|
|
||||||
|
## "Do I own this" has two answers in the schema, and one of them is a flag (2026-08-19)
|
||||||
|
|
||||||
|
Decided while doing #38, and it outlives it because every future
|
||||||
|
catalog surface has to pick one.
|
||||||
|
|
||||||
|
`explore_index` carries both `in_library` and `local_artist_id` /
|
||||||
|
`local_release_group_id` / `local_recording_id`. They are written by
|
||||||
|
the same pass (`collectLibraryEntities`), so on a healthy database they
|
||||||
|
agree, and the code read them as an OR — `inLibrary || localId > 0` —
|
||||||
|
at eight call sites.
|
||||||
|
|
||||||
|
They are not the same kind of thing:
|
||||||
|
|
||||||
|
- **`local_*_id` is a fact with an owner.** Every query that sets one
|
||||||
|
joins `audio_files`, and `pruneStaleLocalCrossReferences` clears it
|
||||||
|
with an existence test that is a file test in all three cases. It is
|
||||||
|
the same rule `explore-album-details`'s `filePaths` implements, one
|
||||||
|
layer down and computed once per scan.
|
||||||
|
- **`in_library` is a ratchet.** `upsertBatch` raises it with
|
||||||
|
`MAX(in_library, excluded.in_library)` and the prune is the only
|
||||||
|
thing that lowers it — gated on the local id being non-null, so a row
|
||||||
|
holding the flag *without* an id is a fixed point nothing can clear.
|
||||||
|
Filed as #118; it still drives search scoring, the popularity-floor
|
||||||
|
bypass and two Explore shelves, so routing the UI around it was not a
|
||||||
|
fix.
|
||||||
|
|
||||||
|
What made the choice concrete rather than theoretical: on
|
||||||
|
`explore-artist-details` the *same card* used both. The context menu
|
||||||
|
gated Play on `localId > 0`; the badge used `inLibrary`. An album with
|
||||||
|
the flag and no local row drew a green tick saying it was in your
|
||||||
|
library, offered no Play, and — the request item being gated on *not*
|
||||||
|
owned — offered no way to ask for it either.
|
||||||
|
|
||||||
|
The rejected alternative is worth keeping: batching a real file lookup
|
||||||
|
per screenful, the way `credit-store` coalesces. It would have answered
|
||||||
|
for **recordings** (`GetFilePathsByRecordingMBIDs`) and most of the
|
||||||
|
cards on these surfaces are release groups, so it would have made track
|
||||||
|
rows strong, left album cards exactly where they were, and cost a new
|
||||||
|
store. The batch that *was* worth adding is a different question —
|
||||||
|
`GetAlbumsCompleteness`, "how much of this album is here", which no
|
||||||
|
per-card flag can answer at all.
|
||||||
|
|
||||||
|
The general point: **two columns that agree today are not one column.**
|
||||||
|
Which of them a new surface reads should be decided by which one has
|
||||||
|
something that can un-set it.
|
||||||
|
|
||||||
|
## The queue panel was a column that could not afford to be one (measured 2026-08-19)
|
||||||
|
|
||||||
|
Plan 018, issue #24. Measured against the running app (`make
|
||||||
|
dev-headless SEED=default`, Chromium) on Playlists, sweeping the
|
||||||
|
viewport with the queue open and closed. Main panel width, and how much
|
||||||
|
of the page header survived:
|
||||||
|
|
||||||
|
| viewport | sidebar | main (queue open) | actions clipped |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1280×800 | 200 | 759 | — |
|
||||||
|
| 1000×700 | 200 | 479 | 2 of 3 |
|
||||||
|
| **900×600** | 200 | **379** | all three |
|
||||||
|
| 800×600 | 56 | 423 | all three |
|
||||||
|
| 390×780 | — | **69** | all three |
|
||||||
|
| 320×600 | — | **0** | all three |
|
||||||
|
| 800×600 | 56 | 744 *(closed)* | New Smart Playlist, 158/162px |
|
||||||
|
|
||||||
|
Five things came out of it that the issue did not say.
|
||||||
|
|
||||||
|
- **The header clips at the enforced minimum with the queue closed.**
|
||||||
|
800×600 is the only size this app promises, and "New Smart Playlist"
|
||||||
|
loses 4px of its 162 there. The queue makes it dramatic; it is not
|
||||||
|
the cause.
|
||||||
|
- **900×600 is worse than 800×600.** `AUTO_COLLAPSE_VIEWPORT` collapses
|
||||||
|
the sidebar *below* 900, so the main panel is 843px at 899 and 700px
|
||||||
|
at 900. **The worst desktop case is the top of the Compact band, not
|
||||||
|
the enforced floor** — so every viewport list that stopped at "the
|
||||||
|
minimum" was missing its own worst case. `layout-overflow.spec.ts`
|
||||||
|
carries 900 now.
|
||||||
|
- **At 320px the main panel was 0px.** The panel is `flex-shrink: 0` in
|
||||||
|
the flow of `.content-area`, so an open queue is paid for by the
|
||||||
|
content rather than covering it. Not degraded — gone. That is the
|
||||||
|
measurement #55 wanted and did not have.
|
||||||
|
- **Only Playlists overflows.** All ten primary views swept at 900×600
|
||||||
|
and 390×780; every other header reports `scrollWidth ==
|
||||||
|
clientWidth`, and Albums at 390 renders title, count and sort legibly
|
||||||
|
(checked on a screenshot, not just the number). So #69 is one view's
|
||||||
|
action set — three text buttons totalling 390px — and not a systemic
|
||||||
|
header failure.
|
||||||
|
- **Both reasons in `MinWidth`'s comment had expired.** The subtitle is
|
||||||
|
`display: none` from 899 down, and the sidebar host is
|
||||||
|
`overflow-y: auto` (at 600×460, `scrollHeight` 434 against a 332px
|
||||||
|
client, Settings reachable after scrolling). The floor is right; its
|
||||||
|
stated defence was two mechanisms that can no longer happen, which is
|
||||||
|
worse than either answer because nobody can argue with it.
|
||||||
|
|
||||||
|
**A correction worth keeping, because it nearly went in the plan.** My
|
||||||
|
first probe for the sidebar's scroller searched
|
||||||
|
`shadowRoot.querySelectorAll('*')` and reported "no scroller — items
|
||||||
|
are unreachable", which reads exactly like a live Settings-unreachable
|
||||||
|
bug. The scroller is the **host**, and a host is not inside its own
|
||||||
|
shadow root. CLAUDE.md was right and the probe was wrong.
|
||||||
|
|
||||||
|
**And one claim in the plan's first draft was too strong**: that the
|
||||||
|
overlay "removes the desktop half of #69". After phase 2, at 900×600,
|
||||||
|
open and closed are now *identical* (main 700, one action clipped)
|
||||||
|
where open used to be main 379 with all three clipped. The queue's
|
||||||
|
contribution is gone; the header's own overflow remains and is still a
|
||||||
|
live defect at a supported size.
|
||||||
|
|
||||||
|
### The mode cannot be a media query
|
||||||
|
|
||||||
|
The panel is drag-resizable 200–500px and persisted, so a viewport
|
||||||
|
breakpoint assumes the default 320 and is wrong by up to 180px for a
|
||||||
|
user who widened it — in the direction that hurts, since a wider queue
|
||||||
|
is exactly when the content can least afford it. It is computed from
|
||||||
|
`.content-area`'s width instead (which already accounts for the
|
||||||
|
sidebar's collapse), and the component test that matters widens the
|
||||||
|
panel at a *fixed* parent width and asserts the flip.
|
||||||
|
|
||||||
|
The floor (480) is a judgement, and the measurement is why: there is no
|
||||||
|
cliff. The track list rescales its columns continuously — 213px down to
|
||||||
|
124px between main widths of 900 and 544, `rowOverflow=0` at every step
|
||||||
|
— and the album grid steps 3 columns to 2 somewhere between 564 and 644
|
||||||
|
without breaking. So 480 is anchored at both ends instead: it keeps the
|
||||||
|
default 1100px window inline, and puts every measured-broken case on
|
||||||
|
the overlay side.
|
||||||
|
|
||||||
|
The scrim is perceptible but subtle on a dark ramp, which is worth
|
||||||
|
knowing before someone "fixes" it as broken: sampled from screenshots at
|
||||||
|
900×600, the main panel's background goes 33,37,41 → 18,20,23 and a
|
||||||
|
row's text 242 → 133. It covers the content area only — not the sidebar
|
||||||
|
or the transport — because the queue is not modal.
|
||||||
|
|||||||
@@ -0,0 +1,294 @@
|
|||||||
|
# 018 — Supported sizes, and what the queue panel is
|
||||||
|
|
||||||
|
**Issue:** #24 (`Area/Shell-Nav`, `Priority/High`, `Reviewed/Confirmed`)
|
||||||
|
**Unblocks:** #55 (queue as a screen) — a real Gitea dependency
|
||||||
|
**Relates:** #69 (page-header overflow), #12 (mini-player), #51 (small-screen umbrella)
|
||||||
|
**Status:** in flight
|
||||||
|
|
||||||
|
#73 puts this first in Phase 2 and hangs the rest of the phase off it,
|
||||||
|
so the decision has to be written down and arguable before any CSS
|
||||||
|
moves. This document is the decision. Everything below the matrix is
|
||||||
|
either a measurement or an argument for one of the four choices #24
|
||||||
|
asks for.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What is actually wrong, measured
|
||||||
|
|
||||||
|
Against the running app (`make dev-headless SEED=default`, Chromium),
|
||||||
|
Playlists, sweeping the viewport with the queue open and closed. The
|
||||||
|
number that matters is how much of the page header survives.
|
||||||
|
|
||||||
|
| viewport | sidebar | queue | main panel | header needs | actions clipped |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 1280×800 | 200 | open 321 | 759 | 759 | — |
|
||||||
|
| 1000×700 | 200 | open 321 | 479 | 747 | New Playlist, New Smart Playlist |
|
||||||
|
| **900×600** | 200 | open 321 | **379** | 747 | **all three** |
|
||||||
|
| 800×600 | 56 | open 321 | 423 | 747 | all three |
|
||||||
|
| 700×600 | 56 | open 321 | 323 | 747 | all three |
|
||||||
|
| 390×780 | — | open 321 | **69** | 747 | all three |
|
||||||
|
| 320×600 | — | open 321 | **0** | 747 | all three |
|
||||||
|
| 900×600 | 200 | closed | 700 | 747 | New Smart Playlist |
|
||||||
|
| **800×600** | 56 | closed | 744 | 747 | **New Smart Playlist (158/162px)** |
|
||||||
|
| 320×600 | — | closed | 320 | 747 | all three |
|
||||||
|
|
||||||
|
Five things in that table are not in the issue.
|
||||||
|
|
||||||
|
**The header clips at the supported minimum with the queue closed.**
|
||||||
|
At 800×600 — the size `backend/config/window.go` enforces and the only
|
||||||
|
size this app *promises* — "New Smart Playlist" loses 4px of its 162.
|
||||||
|
#24 reads as a queue-panel bug; the queue makes it dramatic, but the
|
||||||
|
header overflows on its own at the minimum window.
|
||||||
|
|
||||||
|
**900×600 is worse than 800×600, because the sidebar expands at 900.**
|
||||||
|
`AUTO_COLLAPSE_VIEWPORT` collapses the sidebar to icons *below* 900, so
|
||||||
|
at 899px the main panel is 843px and at 900px it is 700px. The worst
|
||||||
|
desktop case is therefore not the minimum window; it is the pixel
|
||||||
|
immediately above the collapse. Anything that tests "the minimum" and
|
||||||
|
stops has not tested the worst case, which is what
|
||||||
|
`layout-overflow.spec.ts` does today.
|
||||||
|
|
||||||
|
**At phone widths the queue is not a drawer, it is an amputation.**
|
||||||
|
`queue-panel`'s host is `flex-shrink: 0; width: 0`, going to
|
||||||
|
`width: var(--queue-width, 320px)` under `[open]` — it is *in the flow*
|
||||||
|
of `.content-area`, so it takes its width from the main panel rather
|
||||||
|
than covering it. At 390px that leaves 69px of the page; at 320px it
|
||||||
|
leaves **0px**, and the app is not degraded but gone. This is the
|
||||||
|
measurement #55 needs and did not have.
|
||||||
|
|
||||||
|
**Only Playlists overflows.** Sweeping all ten primary views at 900×600
|
||||||
|
and at 390×780, every other header reports `scrollWidth ==
|
||||||
|
clientWidth`, and Albums at 390px renders title, count and sort
|
||||||
|
legibly (checked on a screenshot, not just the number). #69 is
|
||||||
|
therefore one view's action set — three text buttons totalling 390px —
|
||||||
|
and not a systemic header failure, though the *rule* still belongs in
|
||||||
|
`page-header`.
|
||||||
|
|
||||||
|
**Both reasons in `MinWidth`'s comment are stale.** It says the floor is
|
||||||
|
800×600 because "below ~780 the header's subtitle wraps" and "below
|
||||||
|
~600 tall the eleven sidebar items no longer fit". The subtitle is
|
||||||
|
`display: none` below 900 (index.css), and the sidebar host is
|
||||||
|
`overflow-y: auto` — at 600×460 its `scrollHeight` is 434 against a
|
||||||
|
332px client, and Settings is reachable after scrolling. Neither
|
||||||
|
mechanism can happen any more. That does not mean the floor should
|
||||||
|
move; it means its stated reason no longer supports it, which is worse
|
||||||
|
than either answer.
|
||||||
|
|
||||||
|
*(Care needed: my first probe for the sidebar scroller searched
|
||||||
|
`shadowRoot.querySelectorAll('*')` and reported "items are
|
||||||
|
unreachable", because the scroller is the **host** and a host is not in
|
||||||
|
its own shadow root. The claim in CLAUDE.md is correct.)*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Decision 1 — the supported size matrix
|
||||||
|
|
||||||
|
Three bands. Two of them already exist and are already argued; what is
|
||||||
|
new is that they are written down as a *promise*, and that the queue is
|
||||||
|
part of it.
|
||||||
|
|
||||||
|
| band | width | navigation | queue | promise |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| **Phone** | < 600 | `bottom-nav` + drawer | overlay, full width | reflows; nothing needs sideways scrolling; fits 320px |
|
||||||
|
| **Compact** | 600 – 899 | icon sidebar | overlay + scrim | nothing is clipped or unreachable at any width in the band |
|
||||||
|
| **Desktop** | ≥ 900 | labelled sidebar | inline where it fits (see decision 2), else overlay | as Compact |
|
||||||
|
|
||||||
|
And one promise across all three: **no action is ever unreachable.**
|
||||||
|
That is the sentence #69 asks for and it is the one the matrix exists
|
||||||
|
to make checkable.
|
||||||
|
|
||||||
|
**400% zoom** keeps the meaning it already has: WCAG 1.4.10 names 320px
|
||||||
|
as the reflow target, the phone band covers it, and
|
||||||
|
`layout-overflow.spec.ts` already asserts a 320px viewport needs no
|
||||||
|
sideways scrolling. What changes is that the *queue* must be part of
|
||||||
|
that assertion — it is not today, and with the queue open at 320px the
|
||||||
|
main panel is 0px wide, which no current test can see.
|
||||||
|
|
||||||
|
**The window minimum stays 800×600**, and its comment gets the real
|
||||||
|
reason. The old mechanisms are gone, but the floor is still where the
|
||||||
|
Compact band's chrome stops being comfortable, and lowering it would
|
||||||
|
mean promising the desktop layout at sizes where only the phone layout
|
||||||
|
works. The interesting consequence is decision 4.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Decision 2 — the queue is an overlay when it cannot afford to be a column
|
||||||
|
|
||||||
|
**The rule.** The queue panel renders inline — in the flow, as today —
|
||||||
|
only while
|
||||||
|
|
||||||
|
```
|
||||||
|
viewport − sidebar − queueWidth ≥ 480
|
||||||
|
```
|
||||||
|
|
||||||
|
and as an overlay with a scrim otherwise.
|
||||||
|
|
||||||
|
**Why it cannot be a media query**, which is the load-bearing half:
|
||||||
|
the queue's width is *user state*. It is drag-resizable between 200 and
|
||||||
|
500px and persisted (`--queue-width`, `MIN_WIDTH`/`MAX_WIDTH` in
|
||||||
|
`queue-panel.ts`). A breakpoint at a fixed viewport width silently
|
||||||
|
assumes the default 320, and is wrong by 180px for a user who has
|
||||||
|
dragged the panel wide — in the direction that hurts, since a wider
|
||||||
|
queue is exactly when the content can least afford it. So the mode is
|
||||||
|
computed from the measured widths and published as an attribute, the
|
||||||
|
way `data-active-view` already is, and the CSS keys off that.
|
||||||
|
|
||||||
|
**Why 480, honestly.** There is no cliff to derive it from. The track
|
||||||
|
list rescales its columns continuously — at main widths from 900 down
|
||||||
|
to 544 its `--grid-cols` shrink from 213px to 124px with
|
||||||
|
`rowOverflow=0` throughout — and the album grid steps 3 columns to 2
|
||||||
|
somewhere between 564 and 644 without breaking. So this is a judgement,
|
||||||
|
anchored on two things: it keeps the *default* window (1100 wide, main
|
||||||
|
= 580) inline, because the inline queue is a desktop affordance people
|
||||||
|
choose and turning it into an overlay for the common case would be a
|
||||||
|
regression in feel; and it puts every case measured as broken —
|
||||||
|
900×600 at main=379, and every phone width — on the overlay side.
|
||||||
|
1024×768 lands at main=504 and stays inline.
|
||||||
|
|
||||||
|
**The scrim is the other half of the issue's complaint** ("make the
|
||||||
|
queue obviously an overlay *over* the content so it reads as something
|
||||||
|
to close"). An overlay queue gets a scrim, closes on scrim click and on
|
||||||
|
Escape, and returns focus to `#queue-button`.
|
||||||
|
|
||||||
|
**What must not change**: #55's Direction is explicit — one component,
|
||||||
|
two mount points, do not fork it. The overlay is a *presentation* of
|
||||||
|
the same `queue-panel`, so the roving tab stop, Alt+Arrow reorder, drag
|
||||||
|
reorder, selection semantics and the `virtualizer.requestUpdate()` on
|
||||||
|
selection and current-track change all come along untouched. This
|
||||||
|
decision deliberately stops short of #55's detail-view mount, but it is
|
||||||
|
the shape that makes it possible, and it unblocks it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Decision 3 — #69 is its own PR, and here is the finding that decides it
|
||||||
|
|
||||||
|
`page-header` **cannot collapse its own actions**, and that is not an
|
||||||
|
effort estimate but a fact about the API. Actions arrive through
|
||||||
|
`<slot name="actions">` as arbitrary light-DOM markup — Playlists slots
|
||||||
|
a `<div class="header-actions">` of three `<button>`s with click
|
||||||
|
handlers, drag handlers and a conditional class. A component cannot
|
||||||
|
move another component's light-DOM children into a dropdown and keep
|
||||||
|
their behaviour; there is nothing generic to render as a menu item.
|
||||||
|
|
||||||
|
So the overflow rule needs an *actions API* — hosts declaring
|
||||||
|
`{icon, label, handler, priority}` data that `page-header` can render
|
||||||
|
either as buttons or as menu items — which is a change to all three
|
||||||
|
hosts that slot actions, not a rule added in one place. That is a
|
||||||
|
different piece of work from this one, it is independently verifiable,
|
||||||
|
and the desktop half of #69's symptom is removed by decision 2 anyway
|
||||||
|
(the queue stops eating the header's width).
|
||||||
|
|
||||||
|
It therefore stays #69, gets the finding above recorded on it, and
|
||||||
|
follows immediately after this. What *this* plan owes it is the
|
||||||
|
promise in the matrix — no action unreachable at any supported size —
|
||||||
|
and the measurement that the only offender today is Playlists.
|
||||||
|
|
||||||
|
**And the promise is not kept yet, which is the honest version of a
|
||||||
|
claim this document made in its first draft.** "Decision 2 removes the
|
||||||
|
desktop half of #69's symptom" was too strong. Measured after phase 2,
|
||||||
|
at 900×600 on Playlists:
|
||||||
|
|
||||||
|
| | before | after |
|
||||||
|
|---|---|---|
|
||||||
|
| queue open | main 379px, **all three** actions clipped | main 700px, **one** clipped |
|
||||||
|
| queue closed | main 700px, one clipped | unchanged |
|
||||||
|
|
||||||
|
So the queue's *contribution* is gone — open and closed are now
|
||||||
|
identical, which is the whole of what this decision owed — and the
|
||||||
|
residual "New Smart Playlist: 114/162px" is the header overflowing on
|
||||||
|
its own, at a size the queue never touched. #69 is still a live defect
|
||||||
|
at a supported size, and the matrix's promise is what will close it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Decision 4 — a very small window becomes the phone layout, not the mini-player
|
||||||
|
|
||||||
|
#24 asks whether a very small window should switch to the mini-player
|
||||||
|
(#12) "or simply refuse to go there". Both options in the question are
|
||||||
|
worse than the one the codebase already has.
|
||||||
|
|
||||||
|
**#12 is a second window, not a mode.** Its findings say so: v3
|
||||||
|
supports multiple windows, `AlwaysOnTop` is a window *option*, and the
|
||||||
|
frontend would need an entry branch mounting only the mini-player root
|
||||||
|
for a second window loading the same bundle. Turning the main window
|
||||||
|
into a mini-player at some width conflates the two: it would throw away
|
||||||
|
the user's navigation state on a resize, and it puts the MPRIS question
|
||||||
|
(#12's own open question — media controls are process-level and must
|
||||||
|
not be per-window) on a code path that a drag can trigger by accident.
|
||||||
|
|
||||||
|
**And "refuses" is unnecessary, because the reflow already exists.**
|
||||||
|
The phone band is real, tested, and reached by width alone — a desktop
|
||||||
|
window narrowed below 600px already gets `bottom-nav` and the phone
|
||||||
|
shell. That is a better answer than refusing: it is strictly more
|
||||||
|
usable than a hard minimum, it costs nothing new, and it is the same
|
||||||
|
code Android runs, so it stays exercised.
|
||||||
|
|
||||||
|
So: the main window reflows and never becomes a mini-player; #12 stays
|
||||||
|
a separate always-on-top window and is not blocked by, or coupled to,
|
||||||
|
this decision. The window minimum stays 800×600 for the reason in
|
||||||
|
decision 1 — but the phone band is what happens below it, not a
|
||||||
|
refusal, which is why the minimum is a comfort floor rather than a
|
||||||
|
correctness one.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phases
|
||||||
|
|
||||||
|
1. **This document**, linked from #24, with the matrix reported on the
|
||||||
|
issue and #55 told whether it is unblocked. *(no code)* — **done**
|
||||||
|
2. **The queue's overlay mode** — computed mode attribute, scrim,
|
||||||
|
Escape and scrim-click close, focus return. The inline path is
|
||||||
|
unchanged above the threshold. — **done**
|
||||||
|
3. **The window minimum's comment** — replace both stale reasons with
|
||||||
|
the measured ones. No value change. — **done**
|
||||||
|
4. **Verification**, below. Including the specs that must change
|
||||||
|
because they assert the old behaviour. — **done**
|
||||||
|
|
||||||
|
#69 follows as its own branch; #55 became unblocked at phase 2.
|
||||||
|
|
||||||
|
## What landed, measured
|
||||||
|
|
||||||
|
Main panel width with the queue open, before and after:
|
||||||
|
|
||||||
|
| viewport | before | after | mode |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1280×800 | 759 | 759 | inline |
|
||||||
|
| 1100×720 (default window) | 579 | 579 | inline |
|
||||||
|
| 1024×768 | 503 | 503 | inline |
|
||||||
|
| 900×600 | **379** | **700** | overlay |
|
||||||
|
| 800×600 | 423 | 744 | overlay |
|
||||||
|
| 390×780 | **69** | **390** | overlay |
|
||||||
|
| 320×600 | **0** | **320** | overlay |
|
||||||
|
|
||||||
|
The scrim is perceptible but subtle on a dark ramp, which is worth
|
||||||
|
knowing before someone "fixes" it: sampled from the screenshots at
|
||||||
|
900×600, the main panel's background goes 33,37,41 → 18,20,23 and a
|
||||||
|
row's text 242 → 133. It covers the **content area only** — not the
|
||||||
|
sidebar or the transport — on purpose: the queue is not modal, and
|
||||||
|
leaving the navigation live means the scrim reads as "this is over the
|
||||||
|
content" (which is what #24 asked for) without pretending the rest of
|
||||||
|
the app is unavailable.
|
||||||
|
|
||||||
|
## Verification, and what each tier cannot see
|
||||||
|
|
||||||
|
- `make ui-test` — the queue panel's mode logic is component-tier
|
||||||
|
work and belongs there. It **cannot** see the shell: the threshold is
|
||||||
|
computed from the sidebar and viewport, which do not exist in that
|
||||||
|
tier.
|
||||||
|
- `make e2e` — `layout-overflow.spec.ts` gains the queue-open case at
|
||||||
|
every band (it has none today, which is why main=0px at 320px has
|
||||||
|
never failed anything) and **gains 900×600**, since the minimum is
|
||||||
|
not the worst case. `queue-toggle-state.spec.ts` and
|
||||||
|
`phone-shell.spec.ts` both touch the panel and must be re-read before
|
||||||
|
editing.
|
||||||
|
- **Screenshots at every band, read by a human.** This is not optional
|
||||||
|
here: `layout-overflow.spec.ts` asserts the *shell* needs no sideways
|
||||||
|
scrolling and passes on a build whose album header clips its own
|
||||||
|
buttons (measured this session at 390px; filed on #66). Clipping
|
||||||
|
*inside* a component is invisible to it, and clipping is this issue.
|
||||||
|
- `make ui-visual` **cannot help at all** — the component tier renders
|
||||||
|
the token fallbacks, because the theme only reaches `:root` in the
|
||||||
|
real app.
|
||||||
|
- Accessible names via `page.getByRole(...)`, never a shadow-root
|
||||||
|
query. A drawer with a scrim is exactly the shape that grows a
|
||||||
|
nameless control, and this repo has shipped one three times.
|
||||||
+14
-3
@@ -1,8 +1,19 @@
|
|||||||
# semantic-release configuration.
|
# semantic-release configuration.
|
||||||
#
|
#
|
||||||
# Runs on pushes to main from .gitea/workflows/release.yml: determine the
|
# Run by hand from .gitea/workflows/release.yml, which has no push
|
||||||
# version from the Conventional Commits since the last tag, write the
|
# trigger: determine the version from the Conventional Commits since the
|
||||||
# changelog, commit it, push the tag, and create the Gitea release.
|
# last tag, write the changelog, push the tag, and create the Gitea
|
||||||
|
# release. A release is a shipment rather than a merge, and the commits
|
||||||
|
# accumulate until someone says so -- this file needs to know nothing
|
||||||
|
# about that, because reading everything since the last tag is what it
|
||||||
|
# already did.
|
||||||
|
#
|
||||||
|
# `branches` is main and only main. A `prerelease: true` channel is the
|
||||||
|
# obvious next edit here and is the one to think twice about: all four
|
||||||
|
# publishing workflows trigger on `v*`, which matches `v0.4.0-beta.1`.
|
||||||
|
# They carry a prerelease guard now, so the failure is a clean skip
|
||||||
|
# rather than a beta in a public tap -- but they are four separate files
|
||||||
|
# and this is the line that would turn them on.
|
||||||
#
|
#
|
||||||
# **There is no `@semantic-release/github` plugin here and there must not
|
# **There is no `@semantic-release/github` plugin here and there must not
|
||||||
# be.** Gitea's API is `/api/v1` and is not GitHub's surface. The Gitea
|
# be.** Gitea's API is `/api/v1` and is not GitHub's surface. The Gitea
|
||||||
|
|||||||
+10
-5
@@ -5,15 +5,20 @@ The changelog is the releases page:
|
|||||||
<https://git.ljones.me/yonlu/yellowjacket/releases>
|
<https://git.ljones.me/yonlu/yellowjacket/releases>
|
||||||
|
|
||||||
Every release there is generated from the Conventional Commits it
|
Every release there is generated from the Conventional Commits it
|
||||||
contains, by `.gitea/workflows/release.yml` on merge to `main`. Each one
|
contains, by `.gitea/workflows/release.yml`. Each one carries its notes
|
||||||
carries its notes as its body, grouped by change type, with a link to the
|
as its body, grouped by change type, with a link to the commit behind
|
||||||
commit behind every line.
|
every line.
|
||||||
|
|
||||||
|
That workflow is **run by hand**, so a release holds everything merged
|
||||||
|
since the last one rather than one PR's worth. It used to fire on every
|
||||||
|
push to `main`, which made a version per merged PR (issue #115).
|
||||||
|
|
||||||
**This file is not generated and is not a copy of that.** `main` is a
|
**This file is not generated and is not a copy of that.** `main` is a
|
||||||
protected branch, so nothing pushes a changelog commit back to it — and a
|
protected branch, so nothing pushes a changelog commit back to it — and a
|
||||||
file that claimed to be a changelog while silently never updating would
|
file that claimed to be a changelog while silently never updating would
|
||||||
be worse than no file at all. `make release-dry` prints what the next
|
be worse than no file at all. `make release-dry` prints what a release
|
||||||
merge would release.
|
run would cut right now, and the workflow's own `dry_run` input answers
|
||||||
|
the same question from CI.
|
||||||
|
|
||||||
History before `v0.0.1` is in `git log`. The versions before it were cut
|
History before `v0.0.1` is in `git log`. The versions before it were cut
|
||||||
by hand and are not on the releases page; the entries this file used to
|
by hand and are not on the releases page; the entries this file used to
|
||||||
|
|||||||
@@ -1328,6 +1328,68 @@ is 32px each. Which four is plan 016's committed subset, and everything
|
|||||||
else — Settings included, because a phone still needs it — is behind
|
else — Settings included, because a phone still needs it — is behind
|
||||||
"More".
|
"More".
|
||||||
|
|
||||||
|
**There are three supported size bands, and the queue is part of the
|
||||||
|
promise.** Plan 018 (#24) wrote them down: **Phone** below 600 (bottom
|
||||||
|
nav, reflows, fits 320px exactly), **Compact** 600–899 (icon sidebar),
|
||||||
|
**Desktop** from 900 (labelled sidebar) — plus one sentence across all
|
||||||
|
three, *no action is ever unreachable at any supported size*. The bands
|
||||||
|
themselves already existed; what was new is that they are a promise and
|
||||||
|
that the queue panel is inside it.
|
||||||
|
|
||||||
|
**900 is the worst desktop width, not the 800×600 minimum.** The
|
||||||
|
sidebar collapses to icons *below* 900, so the main panel is 843px at
|
||||||
|
899 and 700px at 900 — the narrowest content area any desktop width
|
||||||
|
produces is at the top of the Compact band, not at the enforced floor.
|
||||||
|
Every viewport list that stopped at "the minimum" was therefore missing
|
||||||
|
its own worst case, which is why `layout-overflow.spec.ts` carries 900
|
||||||
|
now. And **both reasons in `MinWidth`'s comment had expired** — the
|
||||||
|
subtitle is `display: none` from 899 down and the sidebar host scrolls
|
||||||
|
(`overflow-y: auto`; at 600×460 its `scrollHeight` is 434 against a
|
||||||
|
332px client) — so 800×600 is a *comfort* floor for desktop chrome and
|
||||||
|
not a correctness one. Below it the phone layout takes over, which is
|
||||||
|
also why a very small window reflows rather than becoming a
|
||||||
|
mini-player: **#12 is a second always-on-top window, not a mode of this
|
||||||
|
one**, and making it a mode would discard navigation state on a resize
|
||||||
|
and put the process-level MPRIS question on a path a drag can trigger.
|
||||||
|
|
||||||
|
**The queue panel is a column only while the content can spare the
|
||||||
|
width, and that cannot be a media query.** In flow the host is
|
||||||
|
`flex-shrink: 0`, so an open queue is paid for by the main panel: it
|
||||||
|
left 379px at 900×600 (with all three of the Playlists header's actions
|
||||||
|
clipped), 69px at 390, and **0px** at 320 — the content was not
|
||||||
|
degraded but gone. It goes to an overlay with a scrim when
|
||||||
|
`available - panelWidth < 480`, where `available` is
|
||||||
|
`.content-area`'s width and therefore already accounts for the
|
||||||
|
sidebar's collapse.
|
||||||
|
|
||||||
|
Four things about it are load-bearing. **The mode is computed, not
|
||||||
|
breakpointed**, because the panel's width is user state — drag-resizable
|
||||||
|
200–500px and persisted — so a viewport breakpoint silently assumes the
|
||||||
|
default 320 and is wrong by up to 180px in the direction that hurts;
|
||||||
|
widening the panel at a fixed window size must flip it, and
|
||||||
|
`queue-overlay-mode.test.ts` is written around exactly that. **480 is a
|
||||||
|
judgement and says so**: there is no cliff to derive it from (the track
|
||||||
|
list rescales continuously, 213px to 124px columns with no row
|
||||||
|
overflow), so it is anchored to keep the default 1100px window inline
|
||||||
|
and put every measured-broken case on the overlay side. **The scrim
|
||||||
|
covers the content area only** — not the sidebar or the transport —
|
||||||
|
because the queue is not modal, and it is subtle on a dark ramp by
|
||||||
|
arithmetic rather than by accident (33,37,41 → 18,20,23). And **the
|
||||||
|
overlay is a presentation, not a fork**: #55 asks for one component
|
||||||
|
with two mount points, so the roving tab stop, Alt+Arrow reorder, drag
|
||||||
|
reorder, selection semantics and `virtualizer.requestUpdate()` all come
|
||||||
|
along untouched. Escape closes it and returns focus, and is attached
|
||||||
|
only while the overlay is up — it is a dismissal, not a shortcut, which
|
||||||
|
is why it is not a panel-scoped binding.
|
||||||
|
|
||||||
|
What this does **not** fix is `page-header` overflowing on its own:
|
||||||
|
at 900×600 "New Smart Playlist" is still clipped to 114 of 162px with
|
||||||
|
the queue *closed*. That is #69, and it cannot be fixed in
|
||||||
|
`page-header` alone — actions arrive through `<slot name="actions">` as
|
||||||
|
arbitrary light-DOM markup with their own handlers, so collapsing them
|
||||||
|
into a "More actions" menu needs an actions *API* (data, not markup)
|
||||||
|
across all three hosts that slot them.
|
||||||
|
|
||||||
**The phone section of `index.css` is last on purpose.** A media query
|
**The phone section of `index.css` is last on purpose.** A media query
|
||||||
adds no specificity, so a `@media (max-width: 599px)` block placed
|
adds no specificity, so a `@media (max-width: 599px)` block placed
|
||||||
above the plain rules it overrides loses to them — which is how phase 1
|
above the plain rules it overrides loses to them — which is how phase 1
|
||||||
@@ -1448,6 +1510,52 @@ is therefore **reported at runtime** to `window.__yjIconMisses` and
|
|||||||
drawn as a fallback — an e2e sweep asserts there are none — since a
|
drawn as a fallback — an e2e sweep asserts there are none — since a
|
||||||
missing icon used to be impossible, the CDN having had everything.
|
missing icon used to be impossible, the CDN having had everything.
|
||||||
|
|
||||||
|
**What each icon *means* is a second table, and it is
|
||||||
|
`utils/icon-language.ts`.** Bundling answers "does this name resolve";
|
||||||
|
nothing answered "does this name mean what the one next to it means",
|
||||||
|
and a wrong-but-real icon renders perfectly. So `plus` came to mean add
|
||||||
|
to the queue, add to a playlist, make a new playlist **and** you do not
|
||||||
|
own this — the first two *adjacent in the same context menu* — while
|
||||||
|
`list` meant the queue, the Playlists destination and adding to the
|
||||||
|
queue.
|
||||||
|
|
||||||
|
The rule the table is built on: **an icon names the noun it acts on,
|
||||||
|
not the verb.** "Add to queue" and "add to playlist" are one verb on
|
||||||
|
two nouns, so the noun is what has to differ — which is why adding to a
|
||||||
|
playlist wears the Playlists destination's own icon, and why the queue
|
||||||
|
got `bars-staggered` and stopped wearing Playlists'. `plus` keeps the
|
||||||
|
one meaning it is unambiguous about, making something that is not there
|
||||||
|
yet.
|
||||||
|
|
||||||
|
Four things about it are load-bearing:
|
||||||
|
|
||||||
|
- **The request toggle is one glyph in two weights**
|
||||||
|
(`regular/bookmark` → `solid/bookmark`), because two states of a
|
||||||
|
toggle have to read as each other's opposite and a plus against a
|
||||||
|
bookmark does not. The pair was *already in the app and already
|
||||||
|
right* on `explore-album-details`'s "Request this" button while the
|
||||||
|
badge forty pixels away showed a plus — `utils/library-status.ts`'s
|
||||||
|
fault one layer down, having made the two agree on what wanting means
|
||||||
|
and left them disagreeing on what it looks like.
|
||||||
|
- **Downloads keeps the solid bookmark, deliberately.** That is the
|
||||||
|
same word twice, not two words: the badge says "this is on your
|
||||||
|
list" and the nav item is that list.
|
||||||
|
- **`icon-language.test.ts` sweeps the source**, because the rule is
|
||||||
|
about every call site and checking one checks nothing — the same
|
||||||
|
shape as `TestNoDirectRuntimeEmits`. It reads every `src/**/*.ts` as
|
||||||
|
raw text and fails on a literal `name="plus"` or `icon: 'list'`
|
||||||
|
outside the table, and its **first assertion is that it read
|
||||||
|
anything at all**, since a sweep over an empty glob passes.
|
||||||
|
- **It also asserts every `ICON_*` is bundled**, which closes the loop
|
||||||
|
the runtime cannot: `bookmark-check` is Font Awesome **Pro** and sat
|
||||||
|
on `explore-artist-details`'s Follow button, drawn for every followed
|
||||||
|
artist as a circled question mark. `offline-icons.spec.ts` sweeps
|
||||||
|
`__yjIconMisses` and could not see it, because no spec had ever
|
||||||
|
followed an artist — the same fault `requested-badge.spec.ts` was
|
||||||
|
written for, one component over, still live. A name computed from
|
||||||
|
state was only checkable from the state; now it is checkable from the
|
||||||
|
table.
|
||||||
|
|
||||||
**An album page says how much of the album is yours.**
|
**An album page says how much of the album is yours.**
|
||||||
`explore-album-details` is a *catalog* page and there is no
|
`explore-album-details` is a *catalog* page and there is no
|
||||||
library-side album detail page at all, so the album on it may be
|
library-side album detail page at all, so the album on it may be
|
||||||
@@ -1612,6 +1720,59 @@ not about plumbing — it says rows may be missing from the page
|
|||||||
altogether, which nothing on screen can show. (`explore-artist-details`
|
altogether, which nothing on screen can show. (`explore-artist-details`
|
||||||
still uses `loading`; it has no equivalent per-row signal.)
|
still uses `loading`; it has no equivalent per-row signal.)
|
||||||
|
|
||||||
|
**And that treatment is the app's, not the page's.**
|
||||||
|
`utils/ownership.ts` is the rule written once, because it was written
|
||||||
|
at eight call sites and so none of them had the whole of it: Explore's
|
||||||
|
cards, `top-results-row` and the artist page's three card shapes all
|
||||||
|
mixed owned and unowned with a small badge as the only difference, and
|
||||||
|
the badge on the *owned* ones was a green tick — the mark on the common
|
||||||
|
case this tracklist removed. Owned is plain and draws no badge at all;
|
||||||
|
unowned is dimmed, says so in its accessible name, and keeps its
|
||||||
|
request affordance; a partly-held album says how partly.
|
||||||
|
|
||||||
|
Four things about it are load-bearing.
|
||||||
|
|
||||||
|
**Ownership is `localId`, and `inLibrary` is deliberately not
|
||||||
|
consulted.** The album page answers with `filePaths`, a real file per
|
||||||
|
displayed track, and a card grid cannot afford that — but it does not
|
||||||
|
need to, because `explore_index.local_*_id` is built by
|
||||||
|
`collectLibraryEntities` from queries that every one join `audio_files`
|
||||||
|
and cleared by `pruneStaleLocalCrossReferences`, whose existence test
|
||||||
|
is a file test in all three cases. That is the same "ownership is a
|
||||||
|
file" rule computed once per scan instead of once per screenful.
|
||||||
|
`in_library` is written by the same pass, so the two agree in a healthy
|
||||||
|
database, but it is a one-way ratchet
|
||||||
|
(`MAX(in_library, excluded.in_library)`) whose only clearing pass is
|
||||||
|
gated on a non-null local id: it cannot be un-set on its own (#118).
|
||||||
|
One is a fact with an owner; the other is a flag that happens to agree.
|
||||||
|
Both `explore-view` and `explore-artist-details` additionally kept a
|
||||||
|
`libraryMBIDs` set that accumulated every MBID ever seen with the flag
|
||||||
|
and cleared it never, in views that never unmount; both are gone.
|
||||||
|
|
||||||
|
**The two answers used to sit on one card.**
|
||||||
|
`renderReleaseMenuItems` gates Play on `release.localId > 0` while the
|
||||||
|
badge used `inLibrary`, so an album with the flag and no local row drew
|
||||||
|
a tick saying it was in your library, offered no Play, and — the
|
||||||
|
request item being gated on *not* owned — offered no way to ask for it
|
||||||
|
either. Any new surface that asks the question twice will reproduce it.
|
||||||
|
|
||||||
|
**`aria-disabled` goes on rows and not on cards.** An unowned *row*
|
||||||
|
cannot be activated; an unowned *card* still navigates to the catalog
|
||||||
|
page for it, which is a perfectly good thing to do with something you
|
||||||
|
do not own. The accessible name carries the state either way, which is
|
||||||
|
why it is one helper and not a class.
|
||||||
|
|
||||||
|
**The count is batched, not looked up.** `store/completeness-store.ts`
|
||||||
|
is `credit-store` one question over: `request()` is per-card and
|
||||||
|
coalesces a screenful into one `GetAlbumsCompleteness`, absence is
|
||||||
|
cached as an answer (or the albums with no totals re-ask forever), and
|
||||||
|
the whole cache is dropped on a scan, a retag or a removal rather than
|
||||||
|
aged. `library-status.ts`'s `albumBadgeFor` is where that meets
|
||||||
|
`Known`: a total that was never declared is a plain `in-library`, never
|
||||||
|
a ring at 0%. One consequence in the badge itself — a `partial` badge
|
||||||
|
is *actionable*, and a control named after its action alone dropped the
|
||||||
|
count from the one state the ring exists for, so its name is both.
|
||||||
|
|
||||||
**A partly-owned album draws the release, not the part.** Once the tags
|
**A partly-owned album draws the release, not the part.** Once the tags
|
||||||
say nine of twelve, `buildLibraryEntry` shows the *catalog's* twelve
|
say nine of twelve, `buildLibraryEntry` shows the *catalog's* twelve
|
||||||
with three dimmed, rather than the nine on disk — the missing tracks
|
with three dimmed, rather than the nine on disk — the missing tracks
|
||||||
@@ -2208,6 +2369,23 @@ Pre-commit hooks verify generated code is fresh — always run `make generate` a
|
|||||||
mistyped `feat` ships a minor version. `make release-dry` answers "what
|
mistyped `feat` ships a minor version. `make release-dry` answers "what
|
||||||
would this merge release" without pushing.
|
would this merge release" without pushing.
|
||||||
|
|
||||||
|
**The analyzer reads the type and ignores the scope, so a CI-only change
|
||||||
|
is `ci:` and never `fix(ci):`.** The scope is decoration; `fix` is a
|
||||||
|
patch whatever is in the brackets. Two commits touching nothing but
|
||||||
|
`.gitea/workflows/unclaim.yml` were written `fix(ci):` and cut `v0.2.1`
|
||||||
|
and `v0.2.2` — real releases, published to Arch, Homebrew and the APK
|
||||||
|
registry, containing no user-facing change. They were left in place
|
||||||
|
rather than deleted, because a version that vanishes is worse for
|
||||||
|
whoever pulled it than one that turns out to be empty.
|
||||||
|
|
||||||
|
**The blast radius is bigger than the version number**, which is what
|
||||||
|
makes this worth a paragraph. A merge to `main` starts two workflows;
|
||||||
|
if `release.yml` then pushes a tag, that tag push starts **four more**
|
||||||
|
(`arch-package`, `homebrew-formula`, `android-apk`, `desktop-assets`) —
|
||||||
|
on a runner with capacity 1, where the APK build alone is tens of
|
||||||
|
minutes. `make release-dry` before merging is how you find out, and it
|
||||||
|
is cheaper than every one of those.
|
||||||
|
|
||||||
**`@semantic-release/github` is not in that config and must not be.**
|
**`@semantic-release/github` is not in that config and must not be.**
|
||||||
Gitea's API is `/api/v1` and is not GitHub's surface, so
|
Gitea's API is `/api/v1` and is not GitHub's surface, so
|
||||||
`@semantic-release/exec` calls `scripts/gitea-release.sh` instead — one
|
`@semantic-release/exec` calls `scripts/gitea-release.sh` instead — one
|
||||||
@@ -2263,13 +2441,53 @@ those run at all; `unclaim.yml` is housekeeping on the tracker and
|
|||||||
touches no code; only `ci.yml` gates, and it is the one to look at when
|
touches no code; only `ci.yml` gates, and it is the one to look at when
|
||||||
deciding whether a push was healthy.
|
deciding whether a push was healthy.
|
||||||
|
|
||||||
**`release.yml` is the entry point for all of it.** On every push to
|
**`release.yml` is the entry point for all of it, and it is triggered by
|
||||||
`main` it reads the Conventional Commits since the last tag and, if any
|
hand.** It reads the Conventional Commits since the last tag and, if any
|
||||||
is releasable, writes the changelog, pushes the tag and creates the Gitea
|
is releasable, writes the changelog, pushes the tag and creates the Gitea
|
||||||
release whose body is that changelog section. `arch-package`,
|
release whose body is that changelog section. `arch-package`,
|
||||||
`homebrew-formula`, `android-apk` and `desktop-assets` are all keyed on
|
`homebrew-formula`, `android-apk` and `desktop-assets` are all keyed on
|
||||||
`v*`, so **the tag push is what starts them** — nothing is released by
|
`v*`, so **the tag push is what starts them** — the version, the notes
|
||||||
hand any more.
|
and the packaging are still nobody's manual work; *when* is the only
|
||||||
|
decision left to a person.
|
||||||
|
|
||||||
|
**It used to fire on every push to `main`, which made the trigger "a PR
|
||||||
|
was merged".** That is a version per unit of *work* rather than per
|
||||||
|
*shipment*: eight releases in twenty-two hours (`v0.0.1` → `v0.3.1`) for
|
||||||
|
one session, each fanning out to four publishers on a runner with
|
||||||
|
capacity 1 — ~40 packaging jobs to ship three issues, with ordinary PR CI
|
||||||
|
queued behind them. Nothing else had to change to batch them, because
|
||||||
|
**semantic-release already reads every commit since the last tag**: five
|
||||||
|
`fix`es and two `feat`s become one minor release with all seven in the
|
||||||
|
notes. Release frequency was only ever how often the workflow fired.
|
||||||
|
|
||||||
|
This is the same rule `index-artifact.yml` states — *a job that mutates
|
||||||
|
state which cannot be rebuilt in ten minutes is triggered deliberately,
|
||||||
|
not by a push* — and the two are now the only workflows with no push
|
||||||
|
trigger. A schedule was considered and rejected: a cron batches without
|
||||||
|
anyone having to remember, but it puts the decision back on a timer,
|
||||||
|
which is the thing being removed. A `beta` integration branch was
|
||||||
|
considered and rejected too (#115): it relocates the trigger rather than
|
||||||
|
removing one, needs a second protected branch carrying the same required
|
||||||
|
checks, and *adds* a full `check` + `e2e` run per batch on the very
|
||||||
|
runner whose queue is the complaint.
|
||||||
|
|
||||||
|
**`dry_run` is why the manual trigger is usable.** The point of pulling
|
||||||
|
a lever by hand is being able to look first, so the dispatch takes a
|
||||||
|
flag that runs `semantic-release --dry-run`: the version and the notes,
|
||||||
|
no tag, no release, no publishers. Anything but the literal string
|
||||||
|
`true` releases for real — a typo in a dispatch box must not silently
|
||||||
|
turn a shipment into a green no-op.
|
||||||
|
|
||||||
|
**A prerelease tag is not a shipment, and all four publishers now say
|
||||||
|
so.** Their trigger is `v*`, which matches `v0.4.0-beta.1`; they guarded
|
||||||
|
`v0.0.0` and nothing else. Nothing produces a prerelease today — the
|
||||||
|
guard is there because the thing that would is `prerelease: true` in
|
||||||
|
`.releaserc.yml`, one line whose blast radius is a public Homebrew tap
|
||||||
|
and a credential-free APK registry that Obtainium polls. `android-apk`
|
||||||
|
is the worst of the four twice over, since its `versionCode` maths
|
||||||
|
splits on dots and would read `1` out of `0-beta` — a wrong number
|
||||||
|
rather than a failed build, and Android refuses anything not greater
|
||||||
|
than what is installed.
|
||||||
|
|
||||||
Four things about it are load-bearing:
|
Four things about it are load-bearing:
|
||||||
|
|
||||||
|
|||||||
@@ -192,15 +192,21 @@ skill-check: ## Fail if the agent docs name a missing make target, or AGENTS.md
|
|||||||
commit-check: ## Fail if a commit subject is not a Conventional Commit
|
commit-check: ## Fail if a commit subject is not a Conventional Commit
|
||||||
@./scripts/commit-check.sh $(if $(RANGE),--range $(RANGE))
|
@./scripts/commit-check.sh $(if $(RANGE),--range $(RANGE))
|
||||||
|
|
||||||
# What a merge to main would release, without releasing it. Reads the
|
# What running the release workflow now would ship, without shipping it.
|
||||||
# same .releaserc.yml CI does, so "why did that not cut a version" is
|
# Reads the same .releaserc.yml CI does, so "why did that not cut a
|
||||||
# answerable locally instead of by pushing and watching. Needs no
|
# version" is answerable locally instead of by pushing and watching.
|
||||||
# credentials: --dry-run neither tags nor publishes.
|
# Needs no credentials: --dry-run neither tags nor publishes.
|
||||||
|
#
|
||||||
|
# release.yml is dispatch-only, so this answers the question that
|
||||||
|
# actually gets asked now -- what has accumulated since the last tag --
|
||||||
|
# rather than what one merge would have done. The workflow's own
|
||||||
|
# `dry_run` input is the same answer from the runner, against whatever
|
||||||
|
# main points at rather than the working tree.
|
||||||
#
|
#
|
||||||
# The pins must stay identical to release.yml's, which is where the note
|
# The pins must stay identical to release.yml's, which is where the note
|
||||||
# on holding the conventionalcommits preset at 9 lives -- at 10 the
|
# on holding the conventionalcommits preset at 9 lives -- at 10 the
|
||||||
# release notes come out empty with everything green.
|
# release notes come out empty with everything green.
|
||||||
release-dry: ## Print the version a merge to main would release
|
release-dry: ## Print the version a release run would cut right now
|
||||||
@npx --yes \
|
@npx --yes \
|
||||||
-p semantic-release@25 \
|
-p semantic-release@25 \
|
||||||
-p @semantic-release/commit-analyzer@13 \
|
-p @semantic-release/commit-analyzer@13 \
|
||||||
|
|||||||
@@ -17,6 +17,34 @@ const (
|
|||||||
RecommendationStrong Recommendation = "strong"
|
RecommendationStrong Recommendation = "strong"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ConfidentTier is the tier at which this package considers a match
|
||||||
|
// good enough to act on without being asked to look.
|
||||||
|
//
|
||||||
|
// It exists as a name rather than as `== RecommendationStrong` at
|
||||||
|
// each call site because two features read it and they must not
|
||||||
|
// disagree about what "high confidence" means: the album page tells
|
||||||
|
// the user unprompted that the autotagger has a match (#28), and
|
||||||
|
// strict auto-accept will rewrite the files without asking (#90).
|
||||||
|
// A page that says "we are sure" about something the auto-accept
|
||||||
|
// pass would decline is the app contradicting itself.
|
||||||
|
//
|
||||||
|
// What the two do *not* share is everything else. Surfacing a match
|
||||||
|
// is a suggestion with a confirm dialog behind it; auto-accept is an
|
||||||
|
// irreversible on-disk rewrite, and #90 gates it on further
|
||||||
|
// conditions this tier cannot express — exact track count, every
|
||||||
|
// title matching, lengths within a couple of seconds, no cover
|
||||||
|
// replacement, no MBID conflict. So this is the floor both stand on,
|
||||||
|
// not the whole of either test.
|
||||||
|
const ConfidentTier = RecommendationStrong
|
||||||
|
|
||||||
|
// Confident reports whether a tier clears ConfidentTier.
|
||||||
|
//
|
||||||
|
// A comparison rather than an equality, so adding a tier above
|
||||||
|
// "strong" later does not silently stop qualifying.
|
||||||
|
func Confident(r Recommendation) bool {
|
||||||
|
return recommendationRank(r) >= recommendationRank(ConfidentTier)
|
||||||
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// Absolute score tiers.
|
// Absolute score tiers.
|
||||||
strongScoreThresh = 0.90
|
strongScoreThresh = 0.90
|
||||||
|
|||||||
@@ -168,3 +168,34 @@ func TestRecommend_LocalCandidatesWithoutRGMBIDCompareByTitle(t *testing.T) {
|
|||||||
t.Errorf("different-title rival: Recommend = %q, want medium", got)
|
t.Errorf("different-title rival: Recommend = %q, want medium", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The tier both features stand on is one name, checked here rather
|
||||||
|
// than assumed at two call sites.
|
||||||
|
//
|
||||||
|
// #28 renders "we have a match for this album" on the album page and
|
||||||
|
// #90 will rewrite files without asking; a page that claims confidence
|
||||||
|
// the auto-accept pass would decline is the app contradicting itself.
|
||||||
|
// What they do not share is everything else — auto-accept adds gates
|
||||||
|
// this tier cannot express — so this pins the floor, not the whole of
|
||||||
|
// either test.
|
||||||
|
func TestConfidentIsTheOneSharedFloor(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
if ConfidentTier != RecommendationStrong {
|
||||||
|
t.Errorf("ConfidentTier = %q, want strong", ConfidentTier)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
rec Recommendation
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{RecommendationNone, false},
|
||||||
|
{RecommendationLow, false},
|
||||||
|
{RecommendationMedium, false},
|
||||||
|
{RecommendationStrong, true},
|
||||||
|
} {
|
||||||
|
if got := Confident(tc.rec); got != tc.want {
|
||||||
|
t.Errorf("Confident(%q) = %v, want %v", tc.rec, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
package autotagservice
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"yellowjacket/backend/autotag"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AlbumMatchView is "the autotagger already has a confident match for
|
||||||
|
// the album you are looking at".
|
||||||
|
//
|
||||||
|
// It is deliberately not a score. The album page renders a suggestion,
|
||||||
|
// and a suggestion has to be actionable: which release, what it is
|
||||||
|
// called, and whether acting on it here would do the whole album or
|
||||||
|
// only part of it.
|
||||||
|
type AlbumMatchView struct {
|
||||||
|
// GroupKey is the tagging group the actions operate on.
|
||||||
|
GroupKey string `json:"groupKey"`
|
||||||
|
|
||||||
|
// Recommendation is the tier, as a string, for a caller that
|
||||||
|
// wants to render the strength rather than trust the filter.
|
||||||
|
Recommendation string `json:"recommendation"`
|
||||||
|
|
||||||
|
// Score is the top candidate's raw score, 0..1.
|
||||||
|
Score float64 `json:"score"`
|
||||||
|
|
||||||
|
// ReleaseMBID is the release Apply would write.
|
||||||
|
ReleaseMBID string `json:"releaseMbid"`
|
||||||
|
|
||||||
|
// Title and ArtistCredit name that release, so the banner can say
|
||||||
|
// what it is offering rather than "a match".
|
||||||
|
Title string `json:"title"`
|
||||||
|
ArtistCredit string `json:"artistCredit"`
|
||||||
|
|
||||||
|
// TrackCount is the group's local track count.
|
||||||
|
TrackCount int64 `json:"trackCount"`
|
||||||
|
|
||||||
|
// GroupCount is how many tagging groups this album spans.
|
||||||
|
//
|
||||||
|
// More than one means a multi-disc album (one group per disc), and
|
||||||
|
// it is the reason this is a field rather than an implementation
|
||||||
|
// detail: applying "the album" from a single button would retag
|
||||||
|
// one disc of three and leave the folder holding a mix of old and
|
||||||
|
// new tags. The caller offers review instead.
|
||||||
|
GroupCount int `json:"groupCount"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MatchForAlbum answers "does the autotagger have something confident
|
||||||
|
// to say about this album", for the album detail page.
|
||||||
|
//
|
||||||
|
// Three things about it are load-bearing.
|
||||||
|
//
|
||||||
|
// **It costs no MusicBrainz request.** Everything it needs is already
|
||||||
|
// on disk: `tagging_items` carries the top score and release from the
|
||||||
|
// background prefetch, and `tagging_candidates` durably holds the
|
||||||
|
// scored list. The rate limiters here are shared with every page the
|
||||||
|
// user can open, so a lookup that fires on page load must not join
|
||||||
|
// that queue — which also means this returns nothing for a folder
|
||||||
|
// nobody has scored yet, rather than scoring it now. That is the
|
||||||
|
// right trade: the prefetch will get to it, and a page that silently
|
||||||
|
// spends a minute of somebody's MusicBrainz budget to draw a banner
|
||||||
|
// is worse than a page that says nothing.
|
||||||
|
//
|
||||||
|
// **The tier is computed, not read.** `tagging_items.score` is the raw
|
||||||
|
// number and `Recommend` is what turns it into a claim — capping it
|
||||||
|
// for an ambiguous runner-up, an incomplete alignment or a folder too
|
||||||
|
// small to corroborate itself. Filtering on the raw score would
|
||||||
|
// promise confidence the scorer had explicitly withheld.
|
||||||
|
//
|
||||||
|
// **Nothing is said about an album the user has already answered
|
||||||
|
// for.** Only a `pending` group qualifies: `confirmed` covers both a
|
||||||
|
// finished apply and an explicit "leave as is", and `skipped` is the
|
||||||
|
// user saying not now. Re-offering either is nagging, and "leave as
|
||||||
|
// is" would be actively wrong to argue with.
|
||||||
|
func (s *Service) MatchForAlbum(albumID int64) (*AlbumMatchView, error) {
|
||||||
|
if albumID <= 0 {
|
||||||
|
return nil, nil //nolint:nilnil // "no album" is not an error.
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := s.db.Queries.GetTaggingItemsForAlbum(
|
||||||
|
s.ctx, sql.NullInt64{Int64: albumID, Valid: true},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("tagging items for album: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
pending := rows[:0:0]
|
||||||
|
|
||||||
|
for _, row := range rows {
|
||||||
|
if row.Status == "pending" {
|
||||||
|
pending = append(pending, row)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(pending) == 0 {
|
||||||
|
return nil, nil //nolint:nilnil // nothing to say is not an error.
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rows arrive best-score-first, so the first pending one is the
|
||||||
|
// group worth describing. On a multi-disc album that is one disc
|
||||||
|
// of several and GroupCount says so.
|
||||||
|
best := pending[0]
|
||||||
|
|
||||||
|
cands := s.lookupCachedCandidates(best.GroupKey)
|
||||||
|
if len(cands) == 0 {
|
||||||
|
return nil, nil //nolint:nilnil // not scored yet; see the doc comment.
|
||||||
|
}
|
||||||
|
|
||||||
|
locals, err := s.scorer.LocalTracksForGroup(s.ctx, best.GroupKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("local tracks for group: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
group := autotag.Group{
|
||||||
|
AlbumName: best.AlbumName,
|
||||||
|
AlbumArtist: best.AlbumArtist,
|
||||||
|
Tracks: locals,
|
||||||
|
Synthetic: best.Synthetic != 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
rec := autotag.Recommend(group, cands)
|
||||||
|
if !autotag.Confident(rec) {
|
||||||
|
return nil, nil //nolint:nilnil // not confident enough to interrupt.
|
||||||
|
}
|
||||||
|
|
||||||
|
top := cands[0]
|
||||||
|
|
||||||
|
// The release the banner names must be the release Apply would
|
||||||
|
// write. Apply with an empty MBID takes the top cached candidate,
|
||||||
|
// which is what this reads — but it is passed explicitly anyway,
|
||||||
|
// so a rescore between the page rendering and the user clicking
|
||||||
|
// cannot swap the album out from under a button they have already
|
||||||
|
// read.
|
||||||
|
return &AlbumMatchView{
|
||||||
|
GroupKey: best.GroupKey,
|
||||||
|
Recommendation: string(rec),
|
||||||
|
Score: top.Score,
|
||||||
|
ReleaseMBID: top.ReleaseMBID,
|
||||||
|
Title: top.Title,
|
||||||
|
ArtistCredit: top.ArtistCredit,
|
||||||
|
TrackCount: best.TrackCount,
|
||||||
|
GroupCount: len(pending),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,320 @@
|
|||||||
|
package autotagservice
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"yellowjacket/backend/autotag"
|
||||||
|
"yellowjacket/backend/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
// seedAlbumGroup writes one album's files, its tagging item and the
|
||||||
|
// durable candidate blob the prefetch would have left behind.
|
||||||
|
//
|
||||||
|
// The candidate list is what a real one looks like in the two ways
|
||||||
|
// that decide the tier: a per-track alignment for every local track,
|
||||||
|
// and a runner-up far enough away not to count as ambiguity.
|
||||||
|
func seedAlbumGroup(
|
||||||
|
t *testing.T,
|
||||||
|
db *database.DB,
|
||||||
|
groupKey string,
|
||||||
|
tracks int,
|
||||||
|
status string,
|
||||||
|
score float64,
|
||||||
|
) int64 {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
for i := 1; i <= tracks; i++ {
|
||||||
|
database.InsertTestTrack(t, db, database.TestTrack{
|
||||||
|
FilePath: filePathFor(groupKey, i),
|
||||||
|
Title: titleFor(i),
|
||||||
|
Artist: "Tideline",
|
||||||
|
Album: "Glass Harbour",
|
||||||
|
AlbumArtist: "Tideline",
|
||||||
|
TrackNumber: int64(i),
|
||||||
|
LengthMs: 200000,
|
||||||
|
LibraryID: 0,
|
||||||
|
GroupKey: groupKey,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := db.ExecContext(`
|
||||||
|
INSERT INTO tagging_items
|
||||||
|
(group_key, library_id, track_count, album_name, album_artist,
|
||||||
|
disc_number, status, score, best_match_release_mbid)
|
||||||
|
VALUES (?, 0, ?, 'Glass Harbour', 'Tideline', 0, ?, ?, 'rel-1')
|
||||||
|
`, groupKey, tracks, status, score); err != nil {
|
||||||
|
t.Fatalf("insert tagging item: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var albumID int64
|
||||||
|
if err := db.QueryRowWriter(
|
||||||
|
`SELECT album_id FROM audio_files WHERE group_key = ? LIMIT 1`, groupKey,
|
||||||
|
).Scan(&albumID); err != nil {
|
||||||
|
t.Fatalf("read album id: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return albumID
|
||||||
|
}
|
||||||
|
|
||||||
|
func filePathFor(groupKey string, n int) string {
|
||||||
|
return "/music/" + groupKey + "/0" + string(rune('0'+n)) + ".mp3"
|
||||||
|
}
|
||||||
|
|
||||||
|
func titleFor(n int) string {
|
||||||
|
return "Track " + string(rune('0'+n))
|
||||||
|
}
|
||||||
|
|
||||||
|
// storeCandidates writes the durable blob GetCandidates would have
|
||||||
|
// cached, with `top` as the winning score.
|
||||||
|
func storeCandidates(
|
||||||
|
t *testing.T, db *database.DB, groupKey string, tracks int, top float64,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
aligns := make([]autotag.TrackAlignment, 0, tracks)
|
||||||
|
for i := range tracks {
|
||||||
|
aligns = append(aligns, autotag.TrackAlignment{
|
||||||
|
Status: autotag.AlignmentMatched,
|
||||||
|
LocalIndex: i,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
cands := []autotag.Candidate{
|
||||||
|
{
|
||||||
|
ReleaseMBID: "rel-1",
|
||||||
|
ReleaseGroupMBID: "rg-1",
|
||||||
|
Title: "Glass Harbour",
|
||||||
|
ArtistCredit: "Tideline",
|
||||||
|
TrackCount: tracks,
|
||||||
|
Alignments: aligns,
|
||||||
|
Score: top,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ReleaseMBID: "rel-2",
|
||||||
|
ReleaseGroupMBID: "rg-2",
|
||||||
|
Title: "Something Else",
|
||||||
|
ArtistCredit: "Another Band",
|
||||||
|
TrackCount: tracks,
|
||||||
|
Score: 0.40,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
blob, err := json.Marshal(cands)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal candidates: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := db.ExecContext(
|
||||||
|
`INSERT INTO tagging_candidates (group_key, candidates) VALUES (?, ?)`,
|
||||||
|
groupKey, string(blob),
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("insert candidates: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A confident match is what the album page exists to surface.
|
||||||
|
func TestMatchForAlbumSurfacesAConfidentMatch(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
db := database.NewTestDB(t)
|
||||||
|
svc := newTestService(t, db)
|
||||||
|
|
||||||
|
albumID := seedAlbumGroup(t, db, "grp-1", 8, "pending", 0.95)
|
||||||
|
storeCandidates(t, db, "grp-1", 8, 0.95)
|
||||||
|
|
||||||
|
got, err := svc.MatchForAlbum(albumID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("MatchForAlbum: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got == nil {
|
||||||
|
t.Fatal("no match returned for a strong candidate")
|
||||||
|
}
|
||||||
|
|
||||||
|
if got.Recommendation != string(autotag.RecommendationStrong) {
|
||||||
|
t.Errorf("recommendation = %q, want strong", got.Recommendation)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The release named is the release Apply would write — the page
|
||||||
|
// must not offer one album and tag another.
|
||||||
|
if got.ReleaseMBID != "rel-1" || got.Title != "Glass Harbour" {
|
||||||
|
t.Errorf("named %q/%q, want rel-1/Glass Harbour", got.ReleaseMBID, got.Title)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got.GroupCount != 1 {
|
||||||
|
t.Errorf("groupCount = %d, want 1", got.GroupCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The tier is computed from the candidates, not read off the raw
|
||||||
|
// score — a high number the scorer would have capped must not reach
|
||||||
|
// the page as confidence it withheld.
|
||||||
|
func TestMatchForAlbumDoesNotTrustTheStoredScore(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
db := database.NewTestDB(t)
|
||||||
|
svc := newTestService(t, db)
|
||||||
|
|
||||||
|
// Two tracks: below the evidence floor, so `Recommend` caps this
|
||||||
|
// at medium however well it scores.
|
||||||
|
albumID := seedAlbumGroup(t, db, "grp-2", 2, "pending", 0.99)
|
||||||
|
storeCandidates(t, db, "grp-2", 2, 0.99)
|
||||||
|
|
||||||
|
got, err := svc.MatchForAlbum(albumID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("MatchForAlbum: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got != nil {
|
||||||
|
t.Errorf("surfaced %+v for a two-track folder, want nothing", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A weak match is not worth interrupting for.
|
||||||
|
func TestMatchForAlbumStaysQuietBelowTheTier(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
db := database.NewTestDB(t)
|
||||||
|
svc := newTestService(t, db)
|
||||||
|
|
||||||
|
albumID := seedAlbumGroup(t, db, "grp-3", 8, "pending", 0.60)
|
||||||
|
storeCandidates(t, db, "grp-3", 8, 0.60)
|
||||||
|
|
||||||
|
got, err := svc.MatchForAlbum(albumID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("MatchForAlbum: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got != nil {
|
||||||
|
t.Errorf("surfaced %+v for a 0.60 match, want nothing", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An album the user has already answered for is not re-offered.
|
||||||
|
//
|
||||||
|
// `confirmed` covers both a finished apply and an explicit "leave as
|
||||||
|
// is", and arguing with the second would be actively wrong.
|
||||||
|
func TestMatchForAlbumRespectsAnAnswerAlreadyGiven(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, status := range []string{"confirmed", "skipped", "matched"} {
|
||||||
|
t.Run(status, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
db := database.NewTestDB(t)
|
||||||
|
svc := newTestService(t, db)
|
||||||
|
|
||||||
|
albumID := seedAlbumGroup(t, db, "grp-"+status, 8, status, 0.95)
|
||||||
|
storeCandidates(t, db, "grp-"+status, 8, 0.95)
|
||||||
|
|
||||||
|
got, err := svc.MatchForAlbum(albumID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("MatchForAlbum: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got != nil {
|
||||||
|
t.Errorf("surfaced %+v for a %s group, want nothing", got, status)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A folder nobody has scored yet says nothing, rather than scoring it
|
||||||
|
// now: the MusicBrainz limiter is shared with every page the user can
|
||||||
|
// open, and this runs on page load.
|
||||||
|
func TestMatchForAlbumMakesNoNetworkCallForAnUnscoredFolder(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
db := database.NewTestDB(t)
|
||||||
|
svc := newTestService(t, db)
|
||||||
|
|
||||||
|
// No storeCandidates: the prefetch has not reached this folder.
|
||||||
|
albumID := seedAlbumGroup(t, db, "grp-4", 8, "pending", 0.95)
|
||||||
|
|
||||||
|
got, err := svc.MatchForAlbum(albumID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("MatchForAlbum: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got != nil {
|
||||||
|
t.Errorf("surfaced %+v with no cached candidates, want nothing", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A multi-disc album is several groups, and the count is what stops
|
||||||
|
// the page offering one button that would retag one disc of two.
|
||||||
|
func TestMatchForAlbumCountsEveryGroupOfTheAlbum(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
db := database.NewTestDB(t)
|
||||||
|
svc := newTestService(t, db)
|
||||||
|
|
||||||
|
albumID := seedAlbumGroup(t, db, "grp-d1", 8, "pending", 0.95)
|
||||||
|
storeCandidates(t, db, "grp-d1", 8, 0.95)
|
||||||
|
|
||||||
|
// Disc two: same album row, its own folder and tagging group.
|
||||||
|
for i := 1; i <= 6; i++ {
|
||||||
|
database.InsertTestTrack(t, db, database.TestTrack{
|
||||||
|
FilePath: filePathFor("grp-d2", i),
|
||||||
|
Title: titleFor(i),
|
||||||
|
Artist: "Tideline",
|
||||||
|
Album: "Glass Harbour",
|
||||||
|
AlbumArtist: "Tideline",
|
||||||
|
TrackNumber: int64(i),
|
||||||
|
DiscNumber: 2,
|
||||||
|
LengthMs: 200000,
|
||||||
|
LibraryID: 0,
|
||||||
|
GroupKey: "grp-d2",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := db.ExecContext(`
|
||||||
|
INSERT INTO tagging_items
|
||||||
|
(group_key, library_id, track_count, album_name, album_artist,
|
||||||
|
disc_number, status, score)
|
||||||
|
VALUES ('grp-d2', 0, 6, 'Glass Harbour', 'Tideline', 2, 'pending', 0.93)
|
||||||
|
`); err != nil {
|
||||||
|
t.Fatalf("insert disc two: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
storeCandidates(t, db, "grp-d2", 6, 0.93)
|
||||||
|
|
||||||
|
got, err := svc.MatchForAlbum(albumID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("MatchForAlbum: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got == nil {
|
||||||
|
t.Fatal("no match returned")
|
||||||
|
}
|
||||||
|
|
||||||
|
if got.GroupCount != 2 {
|
||||||
|
t.Errorf("groupCount = %d, want 2", got.GroupCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Best-first: the 0.95 disc is the one described.
|
||||||
|
if got.GroupKey != "grp-d1" {
|
||||||
|
t.Errorf("described %q, want the higher-scoring grp-d1", got.GroupKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An album with no local files at all — a pure catalog page — is not
|
||||||
|
// a question this can answer.
|
||||||
|
func TestMatchForAlbumSaysNothingWithoutAnAlbum(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
db := database.NewTestDB(t)
|
||||||
|
svc := newTestService(t, db)
|
||||||
|
|
||||||
|
for _, id := range []int64{0, -1, 4242} {
|
||||||
|
got, err := svc.MatchForAlbum(id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("MatchForAlbum(%d): %v", id, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got != nil {
|
||||||
|
t.Errorf("MatchForAlbum(%d) = %+v, want nil", id, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,13 +13,31 @@ const (
|
|||||||
// enforces this at runtime; it is also the floor below which a
|
// enforces this at runtime; it is also the floor below which a
|
||||||
// reported size is treated as bogus and not persisted.
|
// reported size is treated as bogus and not persisted.
|
||||||
//
|
//
|
||||||
// 800x600 is where the shell was measured to still work, rather
|
// **Both reasons this comment used to give have expired**, and the
|
||||||
// than a round number: below ~780 the header's subtitle wraps and
|
// value is right for a third one. It said the floor was 800x600
|
||||||
// pushes the title out of the 4em top bar, and below ~600 tall the
|
// because "below ~780 the header's subtitle wraps and pushes the
|
||||||
// eleven sidebar items no longer fit at once. The previous
|
// title out of the 4em top bar" and "below ~600 tall the eleven
|
||||||
// 512x384 was aspirational — at 700x480 the sidebar overflowed
|
// sidebar items no longer fit at once". Neither mechanism can
|
||||||
// behind the player bar with no scroll and Settings and Jobs could
|
// happen now: the subtitle is display:none from 899px down
|
||||||
// not be reached at all.
|
// (index.css), and the sidebar host is overflow-y:auto — measured
|
||||||
|
// at 600x460, its scrollHeight is 434 against a 332px client and
|
||||||
|
// Settings is reachable after scrolling. A floor defended by two
|
||||||
|
// mechanisms that no longer exist is a number nobody can argue
|
||||||
|
// with, which is worse than either answer.
|
||||||
|
//
|
||||||
|
// It stays 800x600 because that is where the *desktop* chrome
|
||||||
|
// stops being comfortable — the Compact band of plan 018's size
|
||||||
|
// matrix (#24) — and not because the app breaks below it. It does
|
||||||
|
// not: under 600px wide the phone layout takes over (bottom-nav,
|
||||||
|
// no sidebar) and the shell fits 320px exactly, which is what
|
||||||
|
// makes this a comfort floor rather than a correctness one, and
|
||||||
|
// why a very small window reflows instead of becoming a
|
||||||
|
// mini-player (#12 is a second always-on-top window, not a mode of
|
||||||
|
// this one).
|
||||||
|
//
|
||||||
|
// The previous 512x384 was aspirational — at 700x480 the sidebar
|
||||||
|
// overflowed behind the player bar with no scroll and Settings and
|
||||||
|
// Jobs could not be reached at all.
|
||||||
MinWidth = 800
|
MinWidth = 800
|
||||||
// MinHeight is the smallest allowed window height in pixels.
|
// MinHeight is the smallest allowed window height in pixels.
|
||||||
MinHeight = 600
|
MinHeight = 600
|
||||||
|
|||||||
@@ -135,3 +135,49 @@ SELECT
|
|||||||
) AS INTEGER) AS known
|
) AS INTEGER) AS known
|
||||||
FROM audio_files a
|
FROM audio_files a
|
||||||
WHERE a.album_id = sqlc.arg(album_id);
|
WHERE a.album_id = sqlc.arg(album_id);
|
||||||
|
|
||||||
|
-- name: GetAlbumsCompleteness :many
|
||||||
|
-- The same question as GetAlbumCompleteness, asked of a screenful of
|
||||||
|
-- albums at once.
|
||||||
|
--
|
||||||
|
-- A card grid cannot afford one query per card, and the answer it wants
|
||||||
|
-- is the one thing a badge cannot guess: an album held 9 tracks of 12
|
||||||
|
-- must show the count, never a bare tick. So this is one query for the
|
||||||
|
-- whole grid, asked only of the cards that have a local album id.
|
||||||
|
--
|
||||||
|
-- It is two grouping levels rather than the single-album form's
|
||||||
|
-- correlated subqueries, because a correlated subquery in the FROM
|
||||||
|
-- clause is not something SQLite will reliably do -- and because the
|
||||||
|
-- slice may only be spelled once, or sqlc expands it twice with
|
||||||
|
-- independently numbered placeholders.
|
||||||
|
--
|
||||||
|
-- The per-disc level is where the meaning is, and it is the same
|
||||||
|
-- meaning as the single-album query. `owned` counts DISTINCT track
|
||||||
|
-- numbers within a disc (this app detects duplicates, and counting two
|
||||||
|
-- files of track 3 twice would report a short album as complete), with
|
||||||
|
-- a file that declares no track number falling back to its own id
|
||||||
|
-- because three untagged files are three tracks and not one.
|
||||||
|
-- `expected` takes each disc's declared total and sums over discs,
|
||||||
|
-- since a total is declared per disc and a release total written on
|
||||||
|
-- every file of a two-disc album would double its expectation. A disc
|
||||||
|
-- whose files declared nothing contributes a NULL that SUM ignores,
|
||||||
|
-- and `known` is what says the album is therefore unanswerable.
|
||||||
|
WITH per_disc AS (
|
||||||
|
SELECT
|
||||||
|
album_id AS album_id,
|
||||||
|
COUNT(DISTINCT COALESCE(CAST(track_number AS TEXT), 'f' || id))
|
||||||
|
AS owned_on_disc,
|
||||||
|
MAX(total_tracks) AS disc_total,
|
||||||
|
SUM(CASE WHEN total_tracks IS NULL THEN 1 ELSE 0 END)
|
||||||
|
AS discs_without_a_total
|
||||||
|
FROM audio_files
|
||||||
|
WHERE album_id IN (sqlc.slice('album_ids'))
|
||||||
|
GROUP BY album_id, COALESCE(disc_number, 1)
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
CAST(album_id AS INTEGER) AS album_id,
|
||||||
|
CAST(SUM(owned_on_disc) AS INTEGER) AS owned,
|
||||||
|
CAST(COALESCE(SUM(disc_total), 0) AS INTEGER) AS expected,
|
||||||
|
CAST(SUM(discs_without_a_total) = 0 AS INTEGER) AS known
|
||||||
|
FROM per_disc
|
||||||
|
GROUP BY album_id;
|
||||||
|
|||||||
@@ -342,3 +342,35 @@ WHERE ti.status = 'pending'
|
|||||||
)
|
)
|
||||||
ORDER BY ti.group_key
|
ORDER BY ti.group_key
|
||||||
LIMIT 1;
|
LIMIT 1;
|
||||||
|
|
||||||
|
-- name: GetTaggingItemsForAlbum :many
|
||||||
|
-- Every tagging group holding a file of this album.
|
||||||
|
--
|
||||||
|
-- The join is `audio_files.group_key`, not a key derived from the
|
||||||
|
-- album's folder path: a group carved out of a mixed-bag folder by
|
||||||
|
-- SplitMixedFolder is keyed on its tags rather than on a directory,
|
||||||
|
-- so a path-derived key finds nothing for exactly the messiest
|
||||||
|
-- libraries this is meant to help.
|
||||||
|
--
|
||||||
|
-- Usually one row. A multi-disc album is one group per disc, which
|
||||||
|
-- the caller has to know about rather than average over -- applying
|
||||||
|
-- to "the album" would silently retag one disc of three.
|
||||||
|
SELECT
|
||||||
|
ti.group_key,
|
||||||
|
ti.status,
|
||||||
|
ti.score,
|
||||||
|
ti.best_match_release_mbid,
|
||||||
|
ti.track_count,
|
||||||
|
ti.album_name,
|
||||||
|
ti.album_artist,
|
||||||
|
ti.synthetic
|
||||||
|
FROM tagging_items ti
|
||||||
|
WHERE ti.group_key IN (
|
||||||
|
SELECT DISTINCT af.group_key
|
||||||
|
FROM audio_files af
|
||||||
|
WHERE af.album_id = sqlc.arg(album_id) AND af.group_key != ''
|
||||||
|
)
|
||||||
|
AND ti.cleared_at IS NULL
|
||||||
|
-- Best first, with an unscored group last rather than first: NULL
|
||||||
|
-- sorts low in SQLite and DESC would put it at the top.
|
||||||
|
ORDER BY ti.score IS NULL, ti.score DESC, ti.group_key;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ package sqlcgen
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
const deleteAlbum = `-- name: DeleteAlbum :exec
|
const deleteAlbum = `-- name: DeleteAlbum :exec
|
||||||
@@ -234,6 +235,98 @@ func (q *Queries) GetAlbumsByArtistName(ctx context.Context, arg GetAlbumsByArti
|
|||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getAlbumsCompleteness = `-- name: GetAlbumsCompleteness :many
|
||||||
|
WITH per_disc AS (
|
||||||
|
SELECT
|
||||||
|
album_id AS album_id,
|
||||||
|
COUNT(DISTINCT COALESCE(CAST(track_number AS TEXT), 'f' || id))
|
||||||
|
AS owned_on_disc,
|
||||||
|
MAX(total_tracks) AS disc_total,
|
||||||
|
SUM(CASE WHEN total_tracks IS NULL THEN 1 ELSE 0 END)
|
||||||
|
AS discs_without_a_total
|
||||||
|
FROM audio_files
|
||||||
|
WHERE album_id IN (/*SLICE:album_ids*/?)
|
||||||
|
GROUP BY album_id, COALESCE(disc_number, 1)
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
CAST(album_id AS INTEGER) AS album_id,
|
||||||
|
CAST(SUM(owned_on_disc) AS INTEGER) AS owned,
|
||||||
|
CAST(COALESCE(SUM(disc_total), 0) AS INTEGER) AS expected,
|
||||||
|
CAST(SUM(discs_without_a_total) = 0 AS INTEGER) AS known
|
||||||
|
FROM per_disc
|
||||||
|
GROUP BY album_id
|
||||||
|
`
|
||||||
|
|
||||||
|
type GetAlbumsCompletenessRow struct {
|
||||||
|
AlbumID int64
|
||||||
|
Owned int64
|
||||||
|
Expected int64
|
||||||
|
Known int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// The same question as GetAlbumCompleteness, asked of a screenful of
|
||||||
|
// albums at once.
|
||||||
|
//
|
||||||
|
// A card grid cannot afford one query per card, and the answer it wants
|
||||||
|
// is the one thing a badge cannot guess: an album held 9 tracks of 12
|
||||||
|
// must show the count, never a bare tick. So this is one query for the
|
||||||
|
// whole grid, asked only of the cards that have a local album id.
|
||||||
|
//
|
||||||
|
// It is two grouping levels rather than the single-album form's
|
||||||
|
// correlated subqueries, because a correlated subquery in the FROM
|
||||||
|
// clause is not something SQLite will reliably do -- and because the
|
||||||
|
// slice may only be spelled once, or sqlc expands it twice with
|
||||||
|
// independently numbered placeholders.
|
||||||
|
//
|
||||||
|
// The per-disc level is where the meaning is, and it is the same
|
||||||
|
// meaning as the single-album query. `owned` counts DISTINCT track
|
||||||
|
// numbers within a disc (this app detects duplicates, and counting two
|
||||||
|
// files of track 3 twice would report a short album as complete), with
|
||||||
|
// a file that declares no track number falling back to its own id
|
||||||
|
// because three untagged files are three tracks and not one.
|
||||||
|
// `expected` takes each disc's declared total and sums over discs,
|
||||||
|
// since a total is declared per disc and a release total written on
|
||||||
|
// every file of a two-disc album would double its expectation. A disc
|
||||||
|
// whose files declared nothing contributes a NULL that SUM ignores,
|
||||||
|
// and `known` is what says the album is therefore unanswerable.
|
||||||
|
func (q *Queries) GetAlbumsCompleteness(ctx context.Context, albumIds []sql.NullInt64) ([]GetAlbumsCompletenessRow, error) {
|
||||||
|
query := getAlbumsCompleteness
|
||||||
|
var queryParams []interface{}
|
||||||
|
if len(albumIds) > 0 {
|
||||||
|
for _, v := range albumIds {
|
||||||
|
queryParams = append(queryParams, v)
|
||||||
|
}
|
||||||
|
query = strings.Replace(query, "/*SLICE:album_ids*/?", strings.Repeat(",?", len(albumIds))[1:], 1)
|
||||||
|
} else {
|
||||||
|
query = strings.Replace(query, "/*SLICE:album_ids*/?", "NULL", 1)
|
||||||
|
}
|
||||||
|
rows, err := q.db.QueryContext(ctx, query, queryParams...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []GetAlbumsCompletenessRow
|
||||||
|
for rows.Next() {
|
||||||
|
var i GetAlbumsCompletenessRow
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.AlbumID,
|
||||||
|
&i.Owned,
|
||||||
|
&i.Expected,
|
||||||
|
&i.Known,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
const getAlbumsWithPendingReleaseMBID = `-- name: GetAlbumsWithPendingReleaseMBID :many
|
const getAlbumsWithPendingReleaseMBID = `-- name: GetAlbumsWithPendingReleaseMBID :many
|
||||||
SELECT id, pending_release_mbid FROM albums
|
SELECT id, pending_release_mbid FROM albums
|
||||||
WHERE pending_release_mbid IS NOT NULL AND pending_release_mbid != ''
|
WHERE pending_release_mbid IS NOT NULL AND pending_release_mbid != ''
|
||||||
|
|||||||
@@ -231,6 +231,82 @@ func (q *Queries) GetTaggingItem(ctx context.Context, groupKey string) (TaggingI
|
|||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getTaggingItemsForAlbum = `-- name: GetTaggingItemsForAlbum :many
|
||||||
|
SELECT
|
||||||
|
ti.group_key,
|
||||||
|
ti.status,
|
||||||
|
ti.score,
|
||||||
|
ti.best_match_release_mbid,
|
||||||
|
ti.track_count,
|
||||||
|
ti.album_name,
|
||||||
|
ti.album_artist,
|
||||||
|
ti.synthetic
|
||||||
|
FROM tagging_items ti
|
||||||
|
WHERE ti.group_key IN (
|
||||||
|
SELECT DISTINCT af.group_key
|
||||||
|
FROM audio_files af
|
||||||
|
WHERE af.album_id = ?1 AND af.group_key != ''
|
||||||
|
)
|
||||||
|
AND ti.cleared_at IS NULL
|
||||||
|
ORDER BY ti.score IS NULL, ti.score DESC, ti.group_key
|
||||||
|
`
|
||||||
|
|
||||||
|
type GetTaggingItemsForAlbumRow struct {
|
||||||
|
GroupKey string
|
||||||
|
Status string
|
||||||
|
Score sql.NullFloat64
|
||||||
|
BestMatchReleaseMbid sql.NullString
|
||||||
|
TrackCount int64
|
||||||
|
AlbumName string
|
||||||
|
AlbumArtist string
|
||||||
|
Synthetic int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every tagging group holding a file of this album.
|
||||||
|
//
|
||||||
|
// The join is `audio_files.group_key`, not a key derived from the
|
||||||
|
// album's folder path: a group carved out of a mixed-bag folder by
|
||||||
|
// SplitMixedFolder is keyed on its tags rather than on a directory,
|
||||||
|
// so a path-derived key finds nothing for exactly the messiest
|
||||||
|
// libraries this is meant to help.
|
||||||
|
//
|
||||||
|
// Usually one row. A multi-disc album is one group per disc, which
|
||||||
|
// the caller has to know about rather than average over -- applying
|
||||||
|
// to "the album" would silently retag one disc of three.
|
||||||
|
// Best first, with an unscored group last rather than first: NULL
|
||||||
|
// sorts low in SQLite and DESC would put it at the top.
|
||||||
|
func (q *Queries) GetTaggingItemsForAlbum(ctx context.Context, albumID sql.NullInt64) ([]GetTaggingItemsForAlbumRow, error) {
|
||||||
|
rows, err := q.db.QueryContext(ctx, getTaggingItemsForAlbum, albumID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []GetTaggingItemsForAlbumRow
|
||||||
|
for rows.Next() {
|
||||||
|
var i GetTaggingItemsForAlbumRow
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.GroupKey,
|
||||||
|
&i.Status,
|
||||||
|
&i.Score,
|
||||||
|
&i.BestMatchReleaseMbid,
|
||||||
|
&i.TrackCount,
|
||||||
|
&i.AlbumName,
|
||||||
|
&i.AlbumArtist,
|
||||||
|
&i.Synthetic,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
const listAudioFilesInTaggingGroup = `-- name: ListAudioFilesInTaggingGroup :many
|
const listAudioFilesInTaggingGroup = `-- name: ListAudioFilesInTaggingGroup :many
|
||||||
SELECT
|
SELECT
|
||||||
af.id,
|
af.id,
|
||||||
|
|||||||
@@ -2212,6 +2212,7 @@ func (e *Service) gatherTopCandidates(
|
|||||||
ArtistType: a.Type,
|
ArtistType: a.Type,
|
||||||
Country: a.Country,
|
Country: a.Country,
|
||||||
InLibrary: a.InLibrary,
|
InLibrary: a.InLibrary,
|
||||||
|
LocalID: a.LocalID,
|
||||||
},
|
},
|
||||||
category: "artist",
|
category: "artist",
|
||||||
qualityScore: quality,
|
qualityScore: quality,
|
||||||
@@ -2243,6 +2244,7 @@ func (e *Service) gatherTopCandidates(
|
|||||||
ArtistType: a.Type,
|
ArtistType: a.Type,
|
||||||
Country: a.Country,
|
Country: a.Country,
|
||||||
InLibrary: a.InLibrary,
|
InLibrary: a.InLibrary,
|
||||||
|
LocalID: a.LocalID,
|
||||||
},
|
},
|
||||||
category: "artist",
|
category: "artist",
|
||||||
qualityScore: quality,
|
qualityScore: quality,
|
||||||
@@ -2275,6 +2277,7 @@ func (e *Service) gatherTopCandidates(
|
|||||||
PrimaryType: rg.PrimaryType,
|
PrimaryType: rg.PrimaryType,
|
||||||
Year: year,
|
Year: year,
|
||||||
InLibrary: rg.InLibrary,
|
InLibrary: rg.InLibrary,
|
||||||
|
LocalID: rg.LocalID,
|
||||||
},
|
},
|
||||||
category: "release_group",
|
category: "release_group",
|
||||||
qualityScore: quality,
|
qualityScore: quality,
|
||||||
@@ -2320,6 +2323,7 @@ func (e *Service) gatherTopCandidates(
|
|||||||
PrimaryType: rg.PrimaryType,
|
PrimaryType: rg.PrimaryType,
|
||||||
Year: year,
|
Year: year,
|
||||||
InLibrary: rg.InLibrary,
|
InLibrary: rg.InLibrary,
|
||||||
|
LocalID: rg.LocalID,
|
||||||
},
|
},
|
||||||
category: "release_group",
|
category: "release_group",
|
||||||
qualityScore: quality,
|
qualityScore: quality,
|
||||||
@@ -2347,6 +2351,7 @@ func (e *Service) gatherTopCandidates(
|
|||||||
CAAReleaseMBID: r.CAAReleaseMBID,
|
CAAReleaseMBID: r.CAAReleaseMBID,
|
||||||
ReleaseName: r.ReleaseName,
|
ReleaseName: r.ReleaseName,
|
||||||
InLibrary: r.InLibrary,
|
InLibrary: r.InLibrary,
|
||||||
|
LocalID: r.LocalID,
|
||||||
},
|
},
|
||||||
category: "recording",
|
category: "recording",
|
||||||
qualityScore: quality,
|
qualityScore: quality,
|
||||||
@@ -2403,6 +2408,7 @@ func (e *Service) gatherTopCandidates(
|
|||||||
CAAReleaseMBID: r.CAAReleaseMBID,
|
CAAReleaseMBID: r.CAAReleaseMBID,
|
||||||
ReleaseName: r.ReleaseName,
|
ReleaseName: r.ReleaseName,
|
||||||
InLibrary: r.InLibrary,
|
InLibrary: r.InLibrary,
|
||||||
|
LocalID: r.LocalID,
|
||||||
},
|
},
|
||||||
category: "recording",
|
category: "recording",
|
||||||
qualityScore: quality,
|
qualityScore: quality,
|
||||||
@@ -2425,6 +2431,7 @@ func (e *Service) gatherTopCandidates(
|
|||||||
ArtistType: m.ArtistType,
|
ArtistType: m.ArtistType,
|
||||||
Country: m.Country,
|
Country: m.Country,
|
||||||
InLibrary: m.InLibrary || m.LocalArtistID > 0,
|
InLibrary: m.InLibrary || m.LocalArtistID > 0,
|
||||||
|
LocalID: m.LocalArtistID,
|
||||||
},
|
},
|
||||||
category: "artist",
|
category: "artist",
|
||||||
qualityScore: quality,
|
qualityScore: quality,
|
||||||
@@ -2445,6 +2452,7 @@ func (e *Service) gatherTopCandidates(
|
|||||||
PrimaryType: m.PrimaryType,
|
PrimaryType: m.PrimaryType,
|
||||||
Year: year,
|
Year: year,
|
||||||
InLibrary: m.InLibrary || m.LocalReleaseGroupID > 0,
|
InLibrary: m.InLibrary || m.LocalReleaseGroupID > 0,
|
||||||
|
LocalID: m.LocalReleaseGroupID,
|
||||||
},
|
},
|
||||||
category: "release_group",
|
category: "release_group",
|
||||||
qualityScore: quality,
|
qualityScore: quality,
|
||||||
@@ -2459,6 +2467,7 @@ func (e *Service) gatherTopCandidates(
|
|||||||
ArtistMBID: m.ArtistMBID,
|
ArtistMBID: m.ArtistMBID,
|
||||||
Length: m.Duration,
|
Length: m.Duration,
|
||||||
InLibrary: m.InLibrary || m.LocalRecordingID > 0,
|
InLibrary: m.InLibrary || m.LocalRecordingID > 0,
|
||||||
|
LocalID: m.LocalRecordingID,
|
||||||
},
|
},
|
||||||
category: "recording",
|
category: "recording",
|
||||||
qualityScore: quality,
|
qualityScore: quality,
|
||||||
|
|||||||
@@ -41,7 +41,16 @@ type TopResult struct {
|
|||||||
ReleaseGroupMBID string `json:"releaseGroupMbid,omitempty"`
|
ReleaseGroupMBID string `json:"releaseGroupMbid,omitempty"`
|
||||||
ReleaseName string `json:"releaseName,omitempty"`
|
ReleaseName string `json:"releaseName,omitempty"`
|
||||||
// Library status — populated from index cross-reference columns.
|
// Library status — populated from index cross-reference columns.
|
||||||
InLibrary bool `json:"inLibrary"`
|
//
|
||||||
|
// LocalID is the one the cards read. It is the local row behind
|
||||||
|
// this entity — an album, a file, an artist — and it is set and
|
||||||
|
// cleared by a test against `audio_files`, so it means "there is
|
||||||
|
// something of mine here". InLibrary is written by the same pass
|
||||||
|
// but is a one-way ratchet the prune can only clear alongside a
|
||||||
|
// local id, so it is the weaker of the two and stays for scoring
|
||||||
|
// (`fwInLibrary`), which is where an approximate answer is fine.
|
||||||
|
InLibrary bool `json:"inLibrary"`
|
||||||
|
LocalID int64 `json:"localId,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// MBArtist is a Wails-friendly projection of a MusicBrainz artist.
|
// MBArtist is a Wails-friendly projection of a MusicBrainz artist.
|
||||||
|
|||||||
@@ -232,3 +232,112 @@ func TestGetAlbumCompleteness_EmptyAlbum(t *testing.T) {
|
|||||||
t.Errorf("empty album reported %+v, want zero and unknown", got)
|
t.Errorf("empty album reported %+v, want zero and unknown", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The batch and the single-album query are two spellings of one
|
||||||
|
// question, and the thing worth pinning is that they never disagree.
|
||||||
|
//
|
||||||
|
// They are genuinely different SQL — the single-album form is
|
||||||
|
// correlated subqueries over one album, the batch is two grouping
|
||||||
|
// levels over a slice — so the risk is not a typo but a drift in
|
||||||
|
// meaning: a disc's total counted once per file, a duplicate counted
|
||||||
|
// twice, a disc with no total silently covered by one that had one.
|
||||||
|
// Every shape the table above cares about is staged here at once,
|
||||||
|
// because a batch that is only ever asked about one album is not being
|
||||||
|
// asked the question that can go wrong.
|
||||||
|
func TestGetAlbumsCompletenessAgreesWithTheSingleAlbumQuery(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
lib, _ := setupTestLibrary(t)
|
||||||
|
|
||||||
|
shapes := map[int][]track{
|
||||||
|
1: disc(1, 100, 12, 12),
|
||||||
|
2: disc(1, 200, 9, 12),
|
||||||
|
3: disc(1, 300, 13, 12),
|
||||||
|
4: {{recordingID: 400, disc: 1, number: 1}},
|
||||||
|
5: append(disc(1, 500, 10, 10), disc(2, 600, 2, 5)...),
|
||||||
|
6: append(
|
||||||
|
disc(1, 700, 10, 10),
|
||||||
|
track{recordingID: 750, disc: 2, number: 1},
|
||||||
|
),
|
||||||
|
7: append(
|
||||||
|
disc(1, 800, 5, 6),
|
||||||
|
track{recordingID: 899, disc: 1, number: 3, total: 6},
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
ids := make([]int64, 0, len(shapes))
|
||||||
|
|
||||||
|
for albumID, tracks := range shapes {
|
||||||
|
stageAlbum(t, lib, albumID, tracks)
|
||||||
|
ids = append(ids, albumIDFor(t, lib, albumID))
|
||||||
|
}
|
||||||
|
|
||||||
|
batch, err := lib.GetAlbumsCompleteness(ids)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetAlbumsCompleteness: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(batch) != len(ids) {
|
||||||
|
t.Fatalf("batch answered for %d albums, want %d", len(batch), len(ids))
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, id := range ids {
|
||||||
|
one, err := lib.GetAlbumCompleteness(id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetAlbumCompleteness(%d): %v", id, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := batch[id]; got != one {
|
||||||
|
t.Errorf("album %d: batch says %+v, single says %+v", id, got, one)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An album with no files is absent from the batch, not zeroed.
|
||||||
|
//
|
||||||
|
// "I have none of this" and "I have no idea" are the third state Known
|
||||||
|
// exists to keep apart, and a caller reading a missing key gets nothing
|
||||||
|
// rather than a confident zero it would have to know to distrust.
|
||||||
|
func TestGetAlbumsCompletenessOmitsAnAlbumWithNoFiles(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
lib, _ := setupTestLibrary(t)
|
||||||
|
|
||||||
|
stageAlbum(t, lib, 1, disc(1, 100, 3, 3))
|
||||||
|
|
||||||
|
held := albumIDFor(t, lib, 1)
|
||||||
|
|
||||||
|
got, err := lib.GetAlbumsCompleteness([]int64{held, 4242})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetAlbumsCompleteness: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, ok := got[4242]; ok {
|
||||||
|
t.Errorf("an album with no files answered %+v, want absent", got[4242])
|
||||||
|
}
|
||||||
|
|
||||||
|
if !got[held].Complete {
|
||||||
|
t.Errorf("held album reported %+v, want complete", got[held])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A caller with nothing to ask about must not issue a query at all —
|
||||||
|
// sqlc's empty-slice branch rewrites the placeholder to NULL, which is
|
||||||
|
// a perfectly valid query returning nothing, so this is about the round
|
||||||
|
// trip rather than the answer.
|
||||||
|
func TestGetAlbumsCompletenessAsksNothingForAnEmptyList(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
lib, _ := setupTestLibrary(t)
|
||||||
|
|
||||||
|
for _, ids := range [][]int64{nil, {}, {0}, {-1, 0}} {
|
||||||
|
got, err := lib.GetAlbumsCompleteness(ids)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetAlbumsCompleteness(%v): %v", ids, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(got) != 0 {
|
||||||
|
t.Errorf("GetAlbumsCompleteness(%v) = %+v, want empty", ids, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -244,6 +244,65 @@ func (l *Library) GetAlbumCompleteness(albumID int64) (AlbumCompleteness, error)
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetAlbumsCompleteness answers the same question for a screenful of
|
||||||
|
// albums in one query, keyed by album id.
|
||||||
|
//
|
||||||
|
// A card grid asks this about every card that has a local album behind
|
||||||
|
// it, and one query per card is how a grid of fifty albums becomes
|
||||||
|
// fifty round trips. The answer matters there for the reason it
|
||||||
|
// matters on the album page: an album held 9 tracks of 12 has to show
|
||||||
|
// the count, and a bare tick saying "in your library" is the complaint
|
||||||
|
// this whole rule came from.
|
||||||
|
//
|
||||||
|
// An album with no row in the result is one with no files, and it is
|
||||||
|
// absent rather than zeroed — "I have none of this" and "I have no
|
||||||
|
// idea" are the same third state `Known` exists to keep apart, and a
|
||||||
|
// caller reading a missing key gets nothing rather than a confident 0.
|
||||||
|
func (l *Library) GetAlbumsCompleteness(
|
||||||
|
albumIDs []int64,
|
||||||
|
) (map[int64]AlbumCompleteness, error) {
|
||||||
|
out := make(map[int64]AlbumCompleteness, len(albumIDs))
|
||||||
|
|
||||||
|
if len(albumIDs) == 0 {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
keys := make([]sql.NullInt64, 0, len(albumIDs))
|
||||||
|
|
||||||
|
for _, id := range albumIDs {
|
||||||
|
if id <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
keys = append(keys, sql.NullInt64{Int64: id, Valid: true})
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(keys) == 0 {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := l.db.ReadQueries.GetAlbumsCompleteness(l.ctx, keys)
|
||||||
|
if err != nil {
|
||||||
|
l.logger.Error("could not get album completeness in batch",
|
||||||
|
"albums", len(keys), "error", err)
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("could not get album completeness: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, row := range rows {
|
||||||
|
known := row.Known != 0 && row.Expected > 0
|
||||||
|
|
||||||
|
out[row.AlbumID] = AlbumCompleteness{
|
||||||
|
Owned: int(row.Owned),
|
||||||
|
Expected: int(row.Expected),
|
||||||
|
Known: known,
|
||||||
|
Complete: known && row.Owned >= row.Expected,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
// GetAlbumTracks returns one album's tracks in disc/track order.
|
// GetAlbumTracks returns one album's tracks in disc/track order.
|
||||||
func (l *Library) GetAlbumTracks(albumID, libraryID int64) ([]Track, error) {
|
func (l *Library) GetAlbumTracks(albumID, libraryID int64) ([]Track, error) {
|
||||||
rows, err := l.db.ReadQueries.GetTracksByAlbum(
|
rows, err := l.db.ReadQueries.GetTracksByAlbum(
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
package player
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gopxl/beep/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// errTestDecode stands in for a decoder blowing up mid-track.
|
||||||
|
var errTestDecode = errors.New("decode blew up")
|
||||||
|
|
||||||
|
// stalledStreamer never produces a sample and never reports
|
||||||
|
// end-of-stream: (0, true), forever. A damaged file that decodes to
|
||||||
|
// nothing looks like this, and so does any source whose producer has
|
||||||
|
// quietly stopped.
|
||||||
|
type stalledStreamer struct{}
|
||||||
|
|
||||||
|
func (stalledStreamer) Stream(_ [][2]float64) (int, bool) { return 0, true }
|
||||||
|
func (stalledStreamer) Err() error { return nil }
|
||||||
|
|
||||||
|
// failingStreamer produces n good samples and then fails, which is
|
||||||
|
// what a decode error mid-track looks like: the same (0, false) a
|
||||||
|
// finished track returns, distinguishable only by Err.
|
||||||
|
type failingStreamer struct {
|
||||||
|
remaining int
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *failingStreamer) Stream(samples [][2]float64) (int, bool) {
|
||||||
|
if f.remaining <= 0 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
n := min(len(samples), f.remaining)
|
||||||
|
|
||||||
|
for i := range n {
|
||||||
|
samples[i] = [2]float64{1, 1}
|
||||||
|
}
|
||||||
|
|
||||||
|
f.remaining -= n
|
||||||
|
|
||||||
|
return n, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *failingStreamer) Err() error { return f.err }
|
||||||
|
|
||||||
|
// drainUntilEnd calls Stream until it reports end-of-stream, or gives
|
||||||
|
// up. It returns whether the stream ended.
|
||||||
|
//
|
||||||
|
// The give-up bound is wall clock rather than a call count: the stall
|
||||||
|
// budget is a duration, so a tight loop has to actually wait it out.
|
||||||
|
func drainUntilEnd(bs *BufferedStreamer, within time.Duration) bool {
|
||||||
|
buf := make([][2]float64, 512)
|
||||||
|
deadline := time.Now().Add(within)
|
||||||
|
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
if _, ok := bs.Stream(buf); !ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// A source that stops producing without ever ending is the fault this
|
||||||
|
// whole file exists for: Stream used to answer with silence and ok
|
||||||
|
// forever, so the chain never ended, the player stayed in Playing
|
||||||
|
// with the button showing pause, and the decoder's position never
|
||||||
|
// moved -- a frozen seek bar over a track that was not playing.
|
||||||
|
func TestAStalledSourceEndsTheStream(t *testing.T) {
|
||||||
|
bs := NewBufferedStreamer(stalledStreamer{}, 2048)
|
||||||
|
defer bs.Close()
|
||||||
|
|
||||||
|
if !drainUntilEnd(bs, maxStarvedDuration+2*time.Second) {
|
||||||
|
t.Fatal(
|
||||||
|
"a stalled source never ended the stream: the player " +
|
||||||
|
"would sit in Playing with a frozen position",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !errors.Is(bs.Err(), errSourceStalled) {
|
||||||
|
t.Fatalf(
|
||||||
|
"expected the stall to be reported, got %v", bs.Err(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close is the other exit that used to leave `done` false, with the
|
||||||
|
// same consequence: the ring drains and every call after it is
|
||||||
|
// silence that claims to be audio.
|
||||||
|
func TestClosingEndsTheStream(t *testing.T) {
|
||||||
|
bs := NewBufferedStreamer(finiteStreamer(1<<20), 2048)
|
||||||
|
|
||||||
|
// Let the read-ahead fill something, so this exercises the drain
|
||||||
|
// after Close rather than a buffer that was empty anyway.
|
||||||
|
time.Sleep(20 * time.Millisecond)
|
||||||
|
bs.Close()
|
||||||
|
|
||||||
|
if !drainUntilEnd(bs, 2*time.Second) {
|
||||||
|
t.Fatal("a closed streamer never reported end-of-stream")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A source that fails is not a source that finished, and only Err
|
||||||
|
// tells them apart. Before this, the player reported a mid-track
|
||||||
|
// decode failure to the queue as a natural end, so the queue
|
||||||
|
// auto-advanced in silence and counted the broken track as played.
|
||||||
|
func TestAFailedSourceReportsItsError(t *testing.T) {
|
||||||
|
src := &failingStreamer{remaining: 4096, err: errTestDecode}
|
||||||
|
|
||||||
|
bs := NewBufferedStreamer(src, 2048)
|
||||||
|
defer bs.Close()
|
||||||
|
|
||||||
|
if !drainUntilEnd(bs, 2*time.Second) {
|
||||||
|
t.Fatal("a failing source never reported end-of-stream")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !errors.Is(bs.Err(), errTestDecode) {
|
||||||
|
t.Fatalf(
|
||||||
|
"expected the source's error to survive, got %v",
|
||||||
|
bs.Err(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The ordinary case has to keep working: a source that ends cleanly
|
||||||
|
// ends with no error, or every finished track would be reported as a
|
||||||
|
// failure and skipped.
|
||||||
|
func TestADrainedSourceReportsNoError(t *testing.T) {
|
||||||
|
bs := NewBufferedStreamer(finiteStreamer(4096), 2048)
|
||||||
|
defer bs.Close()
|
||||||
|
|
||||||
|
if !drainUntilEnd(bs, 2*time.Second) {
|
||||||
|
t.Fatal("a finite source never reported end-of-stream")
|
||||||
|
}
|
||||||
|
|
||||||
|
if bs.Err() != nil {
|
||||||
|
t.Fatalf(
|
||||||
|
"a track that finished normally reported %v", bs.Err(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A slow source is exactly what the read-ahead exists to absorb, so
|
||||||
|
// underruns must not be charged cumulatively -- otherwise a file on a
|
||||||
|
// slow disk ends itself partway through.
|
||||||
|
func TestUnderrunsDoNotAccumulateAcrossASlowSource(t *testing.T) {
|
||||||
|
const total = 8192
|
||||||
|
|
||||||
|
src := &slowStreamer{
|
||||||
|
inner: finiteStreamer(total),
|
||||||
|
delay: 2 * time.Millisecond,
|
||||||
|
}
|
||||||
|
|
||||||
|
bs := NewBufferedStreamer(src, 1024)
|
||||||
|
defer bs.Close()
|
||||||
|
|
||||||
|
buf := make([][2]float64, 256)
|
||||||
|
got := 0
|
||||||
|
|
||||||
|
for {
|
||||||
|
n, ok := bs.Stream(buf)
|
||||||
|
if !ok {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range n {
|
||||||
|
if buf[i][0] != 0 {
|
||||||
|
got++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if got != total {
|
||||||
|
t.Fatalf(
|
||||||
|
"a slow but healthy source was cut short: got %d of %d "+
|
||||||
|
"samples",
|
||||||
|
got, total,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// beep.Streamer is what the player wraps; keep the type honest.
|
||||||
|
var _ beep.Streamer = (*BufferedStreamer)(nil)
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package player
|
package player
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -34,8 +35,45 @@ type BufferedStreamer struct {
|
|||||||
done bool
|
done bool
|
||||||
err error
|
err error
|
||||||
closed chan struct{}
|
closed chan struct{}
|
||||||
|
|
||||||
|
// starved counts consecutive Stream calls served with silence
|
||||||
|
// because the ring was empty, and starvedSince is when that run
|
||||||
|
// began. An underrun is legitimate for a moment -- that is what
|
||||||
|
// the read-ahead exists to absorb -- but it is not legitimate
|
||||||
|
// forever, and "forever" is indistinguishable from healthy
|
||||||
|
// playback everywhere above this type: the chain never ends, so
|
||||||
|
// the player stays in Playing with the button showing pause, and
|
||||||
|
// the decoder's position never moves, so the 1 Hz report pins the
|
||||||
|
// seek bar and suppresses its interpolation.
|
||||||
|
starved int
|
||||||
|
starvedSince time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The silence fill is bounded by both a duration and a run of calls,
|
||||||
|
// and it needs both.
|
||||||
|
//
|
||||||
|
// Duration alone is the real measure -- the speaker paces itself, so
|
||||||
|
// wall clock is what says whether the source has actually stopped --
|
||||||
|
// but a caller draining in a tight loop (a test, a decode-to-buffer)
|
||||||
|
// makes hundreds of calls in microseconds and would trip nothing.
|
||||||
|
// A call count alone is the opposite failure: the same tight loop
|
||||||
|
// spends the whole budget before the read-ahead goroutine has been
|
||||||
|
// scheduled once, and ends a perfectly good stream at sample zero.
|
||||||
|
//
|
||||||
|
// The duration is longer than the 2 s read-ahead it is there to
|
||||||
|
// outlast, and the count is short enough that the speaker (~200 ms a
|
||||||
|
// call) reaches it well inside that.
|
||||||
|
const (
|
||||||
|
maxStarvedDuration = 3 * time.Second
|
||||||
|
minStarvedCalls = 8
|
||||||
|
)
|
||||||
|
|
||||||
|
// errSourceStalled is returned by Err when the source stopped
|
||||||
|
// producing samples without ever reporting end-of-stream.
|
||||||
|
var errSourceStalled = errors.New(
|
||||||
|
"audio source stopped producing samples",
|
||||||
|
)
|
||||||
|
|
||||||
// NewBufferedStreamer creates a BufferedStreamer that pre-fills
|
// NewBufferedStreamer creates a BufferedStreamer that pre-fills
|
||||||
// bufferSize samples from source via a background goroutine.
|
// bufferSize samples from source via a background goroutine.
|
||||||
// A typical bufferSize is 2× the sample rate (~2 seconds of audio).
|
// A typical bufferSize is 2× the sample rate (~2 seconds of audio).
|
||||||
@@ -54,8 +92,24 @@ func NewBufferedStreamer(
|
|||||||
return bs
|
return bs
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// finish marks the stream ended, recording err as the reason when
|
||||||
|
// there is one. Every exit from readAhead goes through it: an exit
|
||||||
|
// that leaves done false strands Stream in its underrun branch,
|
||||||
|
// where it returns silence and ok forever.
|
||||||
|
func (bs *BufferedStreamer) finish(err error) {
|
||||||
|
bs.mu.Lock()
|
||||||
|
defer bs.mu.Unlock()
|
||||||
|
|
||||||
|
bs.done = true
|
||||||
|
|
||||||
|
if err != nil && bs.err == nil {
|
||||||
|
bs.err = err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// readAhead continuously reads from the source into the ring buffer
|
// readAhead continuously reads from the source into the ring buffer
|
||||||
// until the source is drained, an error occurs, or Close is called.
|
// until the source is drained, an error occurs, or Close is called.
|
||||||
|
// It always marks the stream done on the way out.
|
||||||
func (bs *BufferedStreamer) readAhead() {
|
func (bs *BufferedStreamer) readAhead() {
|
||||||
// Temporary buffer for reading from source outside the lock.
|
// Temporary buffer for reading from source outside the lock.
|
||||||
// 512 samples per chunk keeps the critical section short.
|
// 512 samples per chunk keeps the critical section short.
|
||||||
@@ -63,6 +117,13 @@ func (bs *BufferedStreamer) readAhead() {
|
|||||||
|
|
||||||
tmp := make([][2]float64, chunkSize)
|
tmp := make([][2]float64, chunkSize)
|
||||||
|
|
||||||
|
// Every exit marks the stream done. An exit that does not is what
|
||||||
|
// stranded Stream in its underrun branch, returning silence and ok
|
||||||
|
// for the rest of the process's life.
|
||||||
|
var exitErr error
|
||||||
|
|
||||||
|
defer func() { bs.finish(exitErr) }()
|
||||||
|
|
||||||
for {
|
for {
|
||||||
// Check if closed.
|
// Check if closed.
|
||||||
select {
|
select {
|
||||||
@@ -72,6 +133,15 @@ func (bs *BufferedStreamer) readAhead() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
bs.mu.Lock()
|
bs.mu.Lock()
|
||||||
|
|
||||||
|
// Stream gave up waiting for us. Nothing downstream is
|
||||||
|
// listening any more, so filling the ring is work for nobody.
|
||||||
|
if bs.done {
|
||||||
|
bs.mu.Unlock()
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
space := len(bs.ring) - bs.count
|
space := len(bs.ring) - bs.count
|
||||||
|
|
||||||
if space == 0 {
|
if space == 0 {
|
||||||
@@ -115,14 +185,12 @@ func (bs *BufferedStreamer) readAhead() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !ok {
|
if !ok {
|
||||||
bs.mu.Lock()
|
// A drained source and a failed one both land here and are
|
||||||
bs.done = true
|
// not the same event: one is a track that ended, the other
|
||||||
|
// is a track that broke. Err is what tells them apart, and
|
||||||
if srcErr := bs.source.Err(); srcErr != nil {
|
// it is why the player must ask before treating this as a
|
||||||
bs.err = srcErr
|
// natural finish.
|
||||||
}
|
exitErr = bs.source.Err()
|
||||||
|
|
||||||
bs.mu.Unlock()
|
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -154,7 +222,27 @@ func (bs *BufferedStreamer) Stream(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if bs.count == 0 {
|
if bs.count == 0 {
|
||||||
// Buffer temporarily empty — fill with silence.
|
// The read-ahead has not caught up. Silence buys it time --
|
||||||
|
// but only for a bounded stretch, because "forever" is
|
||||||
|
// reported upward as healthy playback and there is no watchdog
|
||||||
|
// above this to notice otherwise.
|
||||||
|
bs.starved++
|
||||||
|
|
||||||
|
if bs.starvedSince.IsZero() {
|
||||||
|
bs.starvedSince = time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
|
if bs.starved >= minStarvedCalls &&
|
||||||
|
time.Since(bs.starvedSince) > maxStarvedDuration {
|
||||||
|
bs.done = true
|
||||||
|
|
||||||
|
if bs.err == nil {
|
||||||
|
bs.err = errSourceStalled
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
for i := range samples {
|
for i := range samples {
|
||||||
samples[i] = [2]float64{}
|
samples[i] = [2]float64{}
|
||||||
}
|
}
|
||||||
@@ -162,6 +250,9 @@ func (bs *BufferedStreamer) Stream(
|
|||||||
return len(samples), true
|
return len(samples), true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Samples arrived, so whatever the stall was, it is over.
|
||||||
|
bs.resetStarvationLocked()
|
||||||
|
|
||||||
// Copy available samples from ring buffer.
|
// Copy available samples from ring buffer.
|
||||||
n := len(samples)
|
n := len(samples)
|
||||||
if n > bs.count {
|
if n > bs.count {
|
||||||
@@ -197,6 +288,19 @@ func (bs *BufferedStreamer) Flush() {
|
|||||||
bs.readPos = 0
|
bs.readPos = 0
|
||||||
bs.writPos = 0
|
bs.writPos = 0
|
||||||
bs.count = 0
|
bs.count = 0
|
||||||
|
|
||||||
|
// A seek empties the ring on purpose, and the refill that follows
|
||||||
|
// is exactly the stall the budget exists to tolerate. Charging it
|
||||||
|
// against a budget the previous underrun already spent would end
|
||||||
|
// the track on a seek near the end of a slow file.
|
||||||
|
bs.resetStarvationLocked()
|
||||||
|
}
|
||||||
|
|
||||||
|
// resetStarvationLocked forgets an underrun run. Must be called with
|
||||||
|
// bs.mu held.
|
||||||
|
func (bs *BufferedStreamer) resetStarvationLocked() {
|
||||||
|
bs.starved = 0
|
||||||
|
bs.starvedSince = time.Time{}
|
||||||
}
|
}
|
||||||
|
|
||||||
// LockSource blocks the read-ahead goroutine from touching the
|
// LockSource blocks the read-ahead goroutine from touching the
|
||||||
|
|||||||
@@ -0,0 +1,213 @@
|
|||||||
|
package player
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/wailsapp/wails/v3/pkg/application"
|
||||||
|
|
||||||
|
"yellowjacket/backend/events"
|
||||||
|
"yellowjacket/internal/testfixtures"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fixtureSampleRate is what cmd/gentestdata writes (audio.go). It is
|
||||||
|
// deliberately not the speaker rate, which is what lets these tests
|
||||||
|
// tell the decoder's format from the player's default.
|
||||||
|
const fixtureSampleRate = 22050
|
||||||
|
|
||||||
|
// newTestPlayer is a player with a context and no database, so the
|
||||||
|
// track-metadata lookup cannot succeed.
|
||||||
|
func newTestPlayer(t *testing.T) *Player {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
p := NewPlayer(slog.Default(), nil)
|
||||||
|
rec := events.NewRecorder()
|
||||||
|
|
||||||
|
_ = p.ServiceStartup(
|
||||||
|
events.WithSink(t.Context(), rec),
|
||||||
|
application.ServiceOptions{},
|
||||||
|
)
|
||||||
|
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadFileLocked needs no speaker: it decodes, builds the chain and
|
||||||
|
// registers it paused. speaker.Play on an uninitialised device is
|
||||||
|
// what the integration guard elsewhere is about, so these assert on
|
||||||
|
// the state the load computed rather than on playback.
|
||||||
|
|
||||||
|
// p.format used to be assigned once, in the constructor, to the
|
||||||
|
// *speaker's* rate -- so it claimed 44.1 kHz for every file ever
|
||||||
|
// loaded. Play()'s replay-after-finish path resamples from it, so a
|
||||||
|
// finished track played again was resampled from a rate the decoder
|
||||||
|
// never produced: audibly the wrong speed and pitch, and wrong
|
||||||
|
// length and position arithmetic with it.
|
||||||
|
//
|
||||||
|
// The fixtures are 22050 Hz, which is exactly the point -- any of
|
||||||
|
// them disagrees with the speaker rate.
|
||||||
|
func TestLoadRecordsTheDecodersOwnFormat(t *testing.T) {
|
||||||
|
m := testfixtures.Load(t)
|
||||||
|
path := m.Case(t, testfixtures.CaseCoverDedup)[0]
|
||||||
|
|
||||||
|
p := newTestPlayer(t)
|
||||||
|
|
||||||
|
if got := p.format.SampleRate; got != speakerSampleRate {
|
||||||
|
t.Fatalf(
|
||||||
|
"precondition: a fresh player should hold the speaker "+
|
||||||
|
"rate, got %d",
|
||||||
|
got,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := p.LoadFile(path); err != nil {
|
||||||
|
t.Fatalf("LoadFile(%s): %v", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.format.SampleRate == speakerSampleRate {
|
||||||
|
t.Fatalf(
|
||||||
|
"p.format still holds the speaker rate (%d) after "+
|
||||||
|
"loading a %d Hz file: the replay path would "+
|
||||||
|
"resample from the wrong rate",
|
||||||
|
speakerSampleRate, fixtureSampleRate,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := int(p.format.SampleRate); got != fixtureSampleRate {
|
||||||
|
t.Errorf(
|
||||||
|
"expected the decoder's rate %d, got %d",
|
||||||
|
fixtureSampleRate, got,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// trackLengthMs is written only when the database has a row for the
|
||||||
|
// file and cleared only by UnloadTrack, so a track with no row used
|
||||||
|
// to inherit whatever the last track's duration was -- and every
|
||||||
|
// position report is scaled by it, so the whole seek bar was then
|
||||||
|
// reporting one track's progress on another track's scale.
|
||||||
|
//
|
||||||
|
// There is no database here, so the lookup cannot succeed: exactly
|
||||||
|
// the case that used to inherit.
|
||||||
|
func TestLoadDoesNotInheritThePreviousTracksDuration(t *testing.T) {
|
||||||
|
m := testfixtures.Load(t)
|
||||||
|
path := m.Case(t, testfixtures.CaseCoverDedup)[0]
|
||||||
|
|
||||||
|
p := newTestPlayer(t)
|
||||||
|
|
||||||
|
// Stand in for a previous track whose duration was resolved.
|
||||||
|
p.trackLengthMs = 9_999_000
|
||||||
|
|
||||||
|
if err := p.LoadFile(path); err != nil {
|
||||||
|
t.Fatalf("LoadFile(%s): %v", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.trackLengthMs == 9_999_000 {
|
||||||
|
t.Fatal(
|
||||||
|
"the previous track's duration survived the load: every " +
|
||||||
|
"position report for this track would be scaled by it",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A new chain supersedes the old one's pending finished callback.
|
||||||
|
// Without this, a callback that queued for p.mu behind a LoadFile
|
||||||
|
// woke up and rewound, stopped and auto-advanced the *new* track.
|
||||||
|
func TestANewChainSupersedesTheOldFinishedCallback(t *testing.T) {
|
||||||
|
m := testfixtures.Load(t)
|
||||||
|
paths := m.Case(t, testfixtures.CaseCoverDedup)
|
||||||
|
|
||||||
|
if len(paths) < 2 {
|
||||||
|
t.Skip("need two fixture tracks")
|
||||||
|
}
|
||||||
|
|
||||||
|
p := newTestPlayer(t)
|
||||||
|
|
||||||
|
if err := p.LoadFile(paths[0]); err != nil {
|
||||||
|
t.Fatalf("LoadFile(%s): %v", paths[0], err)
|
||||||
|
}
|
||||||
|
|
||||||
|
stale := p.chainID
|
||||||
|
|
||||||
|
if err := p.LoadFile(paths[1]); err != nil {
|
||||||
|
t.Fatalf("LoadFile(%s): %v", paths[1], err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.chainID == stale {
|
||||||
|
t.Fatal("loading a second file did not supersede the chain")
|
||||||
|
}
|
||||||
|
|
||||||
|
called := false
|
||||||
|
|
||||||
|
p.SetPlaybackFinishedHandler(func(error) { called = true })
|
||||||
|
|
||||||
|
// The first track's callback, arriving late.
|
||||||
|
p.onPlaybackFinished(stale, nil)
|
||||||
|
|
||||||
|
if called {
|
||||||
|
t.Error(
|
||||||
|
"a superseded chain's callback drove auto-advance: the " +
|
||||||
|
"track that is loaded now would be skipped",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.state == Stopped {
|
||||||
|
t.Error(
|
||||||
|
"a superseded chain's callback stopped the current track",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The decoder is read by the read-ahead goroutine and by every
|
||||||
|
// position emit, and those used to be guarded by different mutexes:
|
||||||
|
// the read by srcMu, the position by the speaker lock, which
|
||||||
|
// read-ahead never takes. Under -race this failed on the emit that
|
||||||
|
// LoadFile itself makes.
|
||||||
|
//
|
||||||
|
// It needs the read-ahead goroutine to actually be running, so it
|
||||||
|
// keeps asking for the position for long enough to overlap it.
|
||||||
|
func TestPositionReadsDoNotRaceTheReadAhead(t *testing.T) {
|
||||||
|
m := testfixtures.Load(t)
|
||||||
|
path := m.Case(t, testfixtures.CaseFLACAlbum)[0]
|
||||||
|
|
||||||
|
p := newTestPlayer(t)
|
||||||
|
|
||||||
|
if err := p.LoadFile(path); err != nil {
|
||||||
|
t.Fatalf("LoadFile(%s): %v", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for range 200 {
|
||||||
|
if _, err := p.CurrentPositionSeconds(); err != nil {
|
||||||
|
t.Fatalf("CurrentPositionSeconds: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seeking emits the landing position, and that emit reads the
|
||||||
|
// decoder -- so the source lock the seek holds must be released
|
||||||
|
// before it. A reentrant take here is a deadlock, not a failure,
|
||||||
|
// which is why this test exists rather than a comment.
|
||||||
|
func TestSeekEmitsWithoutDeadlocking(t *testing.T) {
|
||||||
|
m := testfixtures.Load(t)
|
||||||
|
path := m.Case(t, testfixtures.CaseFLACAlbum)[0]
|
||||||
|
|
||||||
|
p := newTestPlayer(t)
|
||||||
|
|
||||||
|
if err := p.LoadFile(path); err != nil {
|
||||||
|
t.Fatalf("LoadFile(%s): %v", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
done := make(chan struct{})
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer close(done)
|
||||||
|
|
||||||
|
_ = p.Seek(1)
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(10 * time.Second):
|
||||||
|
t.Fatal("Seek deadlocked: the position emit re-took the source lock")
|
||||||
|
}
|
||||||
|
}
|
||||||
+171
-34
@@ -52,9 +52,17 @@ type Player struct {
|
|||||||
control *beep.Ctrl
|
control *beep.Ctrl
|
||||||
volume *effects.Volume
|
volume *effects.Volume
|
||||||
speakerStreamer beep.Streamer
|
speakerStreamer beep.Streamer
|
||||||
playbackFinishedHandler func()
|
playbackFinishedHandler func(error)
|
||||||
trackChangeID uint64
|
trackChangeID uint64
|
||||||
mediaControls mediacontrols.Handler
|
|
||||||
|
// chainID identifies the streamer chain currently registered with
|
||||||
|
// the speaker. updateStreamers bumps it, and the finished
|
||||||
|
// callback carries the value it was registered with, so a callback
|
||||||
|
// that queued for p.mu behind a LoadFile can tell that the player
|
||||||
|
// has moved on and return rather than rewinding somebody else's
|
||||||
|
// track.
|
||||||
|
chainID uint64
|
||||||
|
mediaControls mediacontrols.Handler
|
||||||
|
|
||||||
// duckAmount is the attenuation currently applied on top of the
|
// duckAmount is the attenuation currently applied on top of the
|
||||||
// user's volume, in the same base-2 exponent effects.Volume uses.
|
// user's volume, in the same base-2 exponent effects.Volume uses.
|
||||||
@@ -180,11 +188,18 @@ func (p *Player) InitSpeaker() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SetPlaybackFinishedHandler sets a callback invoked when a track
|
// SetPlaybackFinishedHandler sets a callback invoked when a track
|
||||||
// finishes naturally. This allows the queue to drive auto-advance
|
// stops streaming. This allows the queue to drive auto-advance
|
||||||
// without circular imports.
|
// without circular imports.
|
||||||
//
|
//
|
||||||
|
// The error says *why* the track stopped: nil for a track that
|
||||||
|
// reached its end, non-nil for one that broke partway through. Both
|
||||||
|
// arrive here because both look identical to the speaker, and only
|
||||||
|
// the queue holds the metadata a PlaybackFailed needs -- but they are
|
||||||
|
// not the same event, and reporting a decode failure as a natural
|
||||||
|
// finish is how a broken file used to auto-advance in silence.
|
||||||
|
//
|
||||||
//wails:ignore // internal wiring, not part of the app's IPC surface.
|
//wails:ignore // internal wiring, not part of the app's IPC surface.
|
||||||
func (p *Player) SetPlaybackFinishedHandler(handler func()) {
|
func (p *Player) SetPlaybackFinishedHandler(handler func(error)) {
|
||||||
p.mu.Lock()
|
p.mu.Lock()
|
||||||
defer p.mu.Unlock()
|
defer p.mu.Unlock()
|
||||||
|
|
||||||
@@ -424,6 +439,19 @@ func (p *Player) updateStreamers(
|
|||||||
newBaseStreamer beep.StreamSeeker,
|
newBaseStreamer beep.StreamSeeker,
|
||||||
sr beep.SampleRate,
|
sr beep.SampleRate,
|
||||||
) error {
|
) error {
|
||||||
|
// A new chain supersedes the old one, so any finished callback the
|
||||||
|
// old one still owes is stale from here on.
|
||||||
|
p.chainID++
|
||||||
|
|
||||||
|
// The previous read-ahead goroutine reads the same decoder this
|
||||||
|
// one is about to, under its own srcMu -- two goroutines, two
|
||||||
|
// mutexes, one decoder that is not safe for concurrent use. The
|
||||||
|
// replay-after-finish path rebuilds from p.seeker without going
|
||||||
|
// through LoadFile, which is where that pair could meet.
|
||||||
|
if p.buffered != nil {
|
||||||
|
p.buffered.Close()
|
||||||
|
}
|
||||||
|
|
||||||
// set base streamer
|
// set base streamer
|
||||||
p.baseStreamer = newBaseStreamer
|
p.baseStreamer = newBaseStreamer
|
||||||
p.seeker = newBaseStreamer
|
p.seeker = newBaseStreamer
|
||||||
@@ -474,23 +502,57 @@ func (p *Player) startPaused() {
|
|||||||
p.control.Paused = true
|
p.control.Paused = true
|
||||||
speaker.Unlock()
|
speaker.Unlock()
|
||||||
|
|
||||||
|
// Captured, not read at callback time: by then p.chainID names
|
||||||
|
// whatever is loaded *now*, which is the thing the guard exists to
|
||||||
|
// distinguish this chain from.
|
||||||
|
chainID := p.chainID
|
||||||
|
buffered := p.buffered
|
||||||
|
|
||||||
// The beep.Callback runs with the speaker mutex held, so we
|
// The beep.Callback runs with the speaker mutex held, so we
|
||||||
// dispatch to a goroutine that can safely acquire p.mu.
|
// dispatch to a goroutine that can safely acquire p.mu.
|
||||||
speaker.Play(beep.Seq(
|
speaker.Play(beep.Seq(
|
||||||
p.speakerStreamer,
|
p.speakerStreamer,
|
||||||
beep.Callback(func() {
|
beep.Callback(func() {
|
||||||
go p.onPlaybackFinished()
|
// Asked here rather than under p.mu: this is the chain that
|
||||||
|
// just ended, and by the time the goroutine holds the lock
|
||||||
|
// p.buffered may be a different one.
|
||||||
|
var err error
|
||||||
|
if buffered != nil {
|
||||||
|
err = buffered.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
go p.onPlaybackFinished(chainID, err)
|
||||||
}),
|
}),
|
||||||
))
|
))
|
||||||
|
|
||||||
p.state = Paused
|
p.state = Paused
|
||||||
}
|
}
|
||||||
|
|
||||||
// onPlaybackFinished handles the natural end of a track. It is
|
// onPlaybackFinished handles a track that stopped streaming, whether
|
||||||
// called on a new goroutine from the beep callback (which holds
|
// it ended or broke. It is called on a new goroutine from the beep
|
||||||
// the speaker lock) so that it can safely acquire p.mu.
|
// callback (which holds the speaker lock) so that it can safely
|
||||||
func (p *Player) onPlaybackFinished() {
|
// acquire p.mu.
|
||||||
|
//
|
||||||
|
// chainID names the streamer chain the callback fired for and srcErr
|
||||||
|
// says why it stopped.
|
||||||
|
func (p *Player) onPlaybackFinished(chainID uint64, srcErr error) {
|
||||||
p.mu.Lock()
|
p.mu.Lock()
|
||||||
|
|
||||||
|
// The player has moved on while this callback queued for the lock
|
||||||
|
// -- a user pressing Next during the last second of a track is
|
||||||
|
// enough. Everything below is about the *current* track: rewinding
|
||||||
|
// the decoder, saying playback stopped, asking the queue to
|
||||||
|
// advance. Doing any of it now would do it to the wrong track.
|
||||||
|
if chainID != p.chainID {
|
||||||
|
p.mu.Unlock()
|
||||||
|
p.logger.Debug(
|
||||||
|
"Ignoring finished callback for a superseded chain",
|
||||||
|
"chain", chainID, "current", p.chainID,
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
p.state = Stopped
|
p.state = Stopped
|
||||||
handler := p.playbackFinishedHandler
|
handler := p.playbackFinishedHandler
|
||||||
mc := p.mediaControls
|
mc := p.mediaControls
|
||||||
@@ -501,10 +563,11 @@ func (p *Player) onPlaybackFinished() {
|
|||||||
// the Stopped state anyway, so this only moves the decoder.
|
// the Stopped state anyway, so this only moves the decoder.
|
||||||
p.rewindLocked()
|
p.rewindLocked()
|
||||||
p.emitPositionLocked()
|
p.emitPositionLocked()
|
||||||
p.mu.Unlock()
|
|
||||||
|
|
||||||
// Emit Wails events outside the lock — these are non-blocking
|
// Emitted under p.mu, like every other transition in this file.
|
||||||
// calls that don't need player state.
|
// Outside it, a Play() taking the lock in the gap emits `playing`
|
||||||
|
// first and this stale `stopped` lands last -- leaving the button
|
||||||
|
// showing play over a track that is audibly running.
|
||||||
p.emitPlaybackFinished()
|
p.emitPlaybackFinished()
|
||||||
|
|
||||||
events.Emit(
|
events.Emit(
|
||||||
@@ -513,6 +576,8 @@ func (p *Player) onPlaybackFinished() {
|
|||||||
map[string]string{"state": string(Stopped)},
|
map[string]string{"state": string(Stopped)},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
p.mu.Unlock()
|
||||||
|
|
||||||
// Notify media controls outside the lock. The track just
|
// Notify media controls outside the lock. The track just
|
||||||
// ended so position is 0.
|
// ended so position is 0.
|
||||||
if mc != nil {
|
if mc != nil {
|
||||||
@@ -521,12 +586,19 @@ func (p *Player) onPlaybackFinished() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
p.logger.Info("Playback finished naturally")
|
if srcErr != nil {
|
||||||
|
p.logger.Error(
|
||||||
|
"Playback stopped: the audio source failed",
|
||||||
|
"err", srcErr,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
p.logger.Info("Playback finished naturally")
|
||||||
|
}
|
||||||
|
|
||||||
// Notify queue for auto-advance. Called without p.mu held
|
// Notify queue for auto-advance. Called without p.mu held
|
||||||
// because it re-enters the player via LoadFile/Play.
|
// because it re-enters the player via LoadFile/Play.
|
||||||
if handler != nil {
|
if handler != nil {
|
||||||
handler()
|
handler(srcErr)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -587,6 +659,18 @@ func (p *Player) loadFileLocked(filePath string) error {
|
|||||||
|
|
||||||
p.currentFile = f
|
p.currentFile = f
|
||||||
|
|
||||||
|
// The decoder's own format, kept for the paths that rebuild the
|
||||||
|
// chain later: Play()'s replay branch resamples from it, so a
|
||||||
|
// stale rate there plays a finished track back at the wrong speed.
|
||||||
|
p.format = format
|
||||||
|
|
||||||
|
// The previous track's duration must not outlive it. This is set
|
||||||
|
// again by emitTrackChanged below, but only when the database has
|
||||||
|
// a row for the file -- and every position this player reports is
|
||||||
|
// scaled by it, so inheriting means every report is wrong by the
|
||||||
|
// ratio between two unrelated tracks.
|
||||||
|
p.trackLengthMs = 0
|
||||||
|
|
||||||
if err := p.updateStreamers(
|
if err := p.updateStreamers(
|
||||||
streamer, format.SampleRate,
|
streamer, format.SampleRate,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
@@ -906,6 +990,8 @@ func (p *Player) CurrentPosition() (int, error) {
|
|||||||
return 0, errNoAudioFileLoaded
|
return 0, errNoAudioFileLoaded
|
||||||
}
|
}
|
||||||
|
|
||||||
|
defer p.lockSourceLocked()()
|
||||||
|
|
||||||
speaker.Lock()
|
speaker.Lock()
|
||||||
pos := math.Round(
|
pos := math.Round(
|
||||||
100.0 * float64(p.seeker.Position()) /
|
100.0 * float64(p.seeker.Position()) /
|
||||||
@@ -924,6 +1010,28 @@ func (p *Player) Seek(targetSeconds int) error {
|
|||||||
return p.seekLocked(targetSeconds)
|
return p.seekLocked(targetSeconds)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// lockSourceLocked blocks the read-ahead goroutine from touching the
|
||||||
|
// decoder and returns the function that releases it, so a caller can
|
||||||
|
// `defer p.lockSourceLocked()()`.
|
||||||
|
//
|
||||||
|
// Reading the decoder's position is a read *of the decoder*, and the
|
||||||
|
// speaker lock does not exclude the read-ahead goroutine -- it never
|
||||||
|
// takes it. That was a genuine data race on every position emit,
|
||||||
|
// once a second for the whole of playback.
|
||||||
|
//
|
||||||
|
// srcMu is not reentrant, so nothing that already holds it may call
|
||||||
|
// this; seekSourceLocked exists to keep that region free of emits.
|
||||||
|
// Must be called with p.mu held.
|
||||||
|
func (p *Player) lockSourceLocked() func() {
|
||||||
|
if p.buffered == nil {
|
||||||
|
return func() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
p.buffered.LockSource()
|
||||||
|
|
||||||
|
return p.buffered.UnlockSource
|
||||||
|
}
|
||||||
|
|
||||||
// rewindLocked returns the decoder to the start of the track without
|
// rewindLocked returns the decoder to the start of the track without
|
||||||
// touching playback state. Must be called with p.mu held.
|
// touching playback state. Must be called with p.mu held.
|
||||||
func (p *Player) rewindLocked() {
|
func (p *Player) rewindLocked() {
|
||||||
@@ -959,6 +1067,46 @@ func (p *Player) seekLocked(targetSeconds int) error {
|
|||||||
return fmt.Errorf("cannot get track length: %w", err)
|
return fmt.Errorf("cannot get track length: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The source lock is released before anything below is emitted:
|
||||||
|
// emitPositionLocked reads the decoder's position and takes the
|
||||||
|
// same lock, which is not reentrant.
|
||||||
|
seekErr := p.seekSourceLocked(targetSeconds, lengthSecs)
|
||||||
|
if seekErr != nil {
|
||||||
|
p.logger.Warn(
|
||||||
|
"Seek failed, playback will start from "+
|
||||||
|
"the beginning",
|
||||||
|
"target-seconds", targetSeconds,
|
||||||
|
"err", seekErr,
|
||||||
|
)
|
||||||
|
|
||||||
|
// The optimistic move the UI already made has to be taken
|
||||||
|
// back, and only the backend knows it did not happen.
|
||||||
|
events.Emit(p.ctx, events.SeekFailed)
|
||||||
|
p.emitPositionLocked()
|
||||||
|
|
||||||
|
return fmt.Errorf("failed to seek: %w", seekErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.mediaControls != nil {
|
||||||
|
p.mediaControls.NotifySeek(targetSeconds)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Report the landing position immediately rather than leaving the
|
||||||
|
// UI to guess until the next tick — this is the half of H-3 that
|
||||||
|
// desynced the seek bar by 30 s over four keyboard seeks.
|
||||||
|
p.emitPositionLocked()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// seekSourceLocked moves the decoder and flushes the stale read-ahead
|
||||||
|
// behind it. It owns the source lock for exactly that long and
|
||||||
|
// emits nothing, so its caller is free to read the position
|
||||||
|
// afterwards. Must be called with p.mu held.
|
||||||
|
func (p *Player) seekSourceLocked(
|
||||||
|
targetSeconds int,
|
||||||
|
lengthSecs int,
|
||||||
|
) error {
|
||||||
// Block the read-ahead goroutine from reading the source while
|
// Block the read-ahead goroutine from reading the source while
|
||||||
// we seek it. The decoder (e.g. FLAC's bufseekio.ReadSeeker) is
|
// we seek it. The decoder (e.g. FLAC's bufseekio.ReadSeeker) is
|
||||||
// not safe for concurrent Read+Seek, and read-ahead runs on its
|
// not safe for concurrent Read+Seek, and read-ahead runs on its
|
||||||
@@ -1014,19 +1162,11 @@ func (p *Player) seekLocked(targetSeconds int) error {
|
|||||||
if seekErr != nil {
|
if seekErr != nil {
|
||||||
speaker.Unlock()
|
speaker.Unlock()
|
||||||
|
|
||||||
p.logger.Warn(
|
p.logger.Debug(
|
||||||
"Seek failed, playback will start from "+
|
"seek rejected by the decoder",
|
||||||
"the beginning",
|
"samples", samples, "err", seekErr,
|
||||||
"target-seconds", targetSeconds,
|
|
||||||
"samples", samples,
|
|
||||||
"err", seekErr,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// The optimistic move the UI already made has to be taken
|
|
||||||
// back, and only the backend knows it did not happen.
|
|
||||||
events.Emit(p.ctx, events.SeekFailed)
|
|
||||||
p.emitPositionLocked()
|
|
||||||
|
|
||||||
return fmt.Errorf("failed to seek: %w", seekErr)
|
return fmt.Errorf("failed to seek: %w", seekErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1039,15 +1179,6 @@ func (p *Player) seekLocked(targetSeconds int) error {
|
|||||||
p.buffered.Flush()
|
p.buffered.Flush()
|
||||||
}
|
}
|
||||||
|
|
||||||
if p.mediaControls != nil {
|
|
||||||
p.mediaControls.NotifySeek(targetSeconds)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Report the landing position immediately rather than leaving the
|
|
||||||
// UI to guess until the next tick — this is the half of H-3 that
|
|
||||||
// desynced the seek bar by 30 s over four keyboard seeks.
|
|
||||||
p.emitPositionLocked()
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1140,6 +1271,10 @@ func (p *Player) seekerLengthSecsLocked() (int, error) {
|
|||||||
return 0, errNoAudioFileLoaded
|
return 0, errNoAudioFileLoaded
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Len is fixed for the life of the decoder, so unlike Position it
|
||||||
|
// races with nothing and needs no source lock -- which it must not
|
||||||
|
// take anyway: displayPositionSecsLocked calls this while holding
|
||||||
|
// it, and srcMu is not reentrant.
|
||||||
speaker.Lock()
|
speaker.Lock()
|
||||||
length := p.seeker.Len() / int(p.format.SampleRate)
|
length := p.seeker.Len() / int(p.format.SampleRate)
|
||||||
speaker.Unlock()
|
speaker.Unlock()
|
||||||
@@ -1156,6 +1291,8 @@ func (p *Player) displayPositionSecsLocked() int {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
defer p.lockSourceLocked()()
|
||||||
|
|
||||||
speaker.Lock()
|
speaker.Lock()
|
||||||
pos := p.seeker.Position()
|
pos := p.seeker.Position()
|
||||||
total := p.seeker.Len()
|
total := p.seeker.Len()
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ func TestFallback_TriggersOnNaturalFinish(t *testing.T) {
|
|||||||
q.SetFallbackSource(fake)
|
q.SetFallbackSource(fake)
|
||||||
|
|
||||||
q.SetQueue(seedPaths, 0, false, Source{Type: "album", ID: 1, Label: "Seed Album"})
|
q.SetQueue(seedPaths, 0, false, Source{Type: "album", ID: 1, Label: "Seed Album"})
|
||||||
q.OnPlaybackFinished()
|
q.OnPlaybackFinished(nil)
|
||||||
|
|
||||||
waitUntil(t, func() bool { return fake.callCount() == 1 }, "fallback to be resolved")
|
waitUntil(t, func() bool { return fake.callCount() == 1 }, "fallback to be resolved")
|
||||||
waitUntil(t, func() bool {
|
waitUntil(t, func() bool {
|
||||||
@@ -159,7 +159,7 @@ func TestFallback_EmptyResultLeavesQueueExhausted(t *testing.T) {
|
|||||||
q.SetFallbackSource(fake)
|
q.SetFallbackSource(fake)
|
||||||
|
|
||||||
q.SetQueue(seedPaths, 0, false, Source{})
|
q.SetQueue(seedPaths, 0, false, Source{})
|
||||||
q.OnPlaybackFinished()
|
q.OnPlaybackFinished(nil)
|
||||||
|
|
||||||
waitUntil(t, func() bool { return fake.callCount() == 1 }, "fallback to be resolved")
|
waitUntil(t, func() bool { return fake.callCount() == 1 }, "fallback to be resolved")
|
||||||
|
|
||||||
@@ -193,7 +193,7 @@ func TestFallback_StaleResolutionDiscarded(t *testing.T) {
|
|||||||
q.SetFallbackSource(fake)
|
q.SetFallbackSource(fake)
|
||||||
|
|
||||||
q.SetQueue(seedPaths, 0, false, Source{})
|
q.SetQueue(seedPaths, 0, false, Source{})
|
||||||
q.OnPlaybackFinished() // starts resolving, blocked on gate
|
q.OnPlaybackFinished(nil) // starts resolving, blocked on gate
|
||||||
|
|
||||||
time.Sleep(20 * time.Millisecond) // let the goroutine reach the gate
|
time.Sleep(20 * time.Millisecond) // let the goroutine reach the gate
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
package queue
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"yellowjacket/backend/events"
|
||||||
|
)
|
||||||
|
|
||||||
|
// errTestDecode stands in for a decoder blowing up mid-track.
|
||||||
|
var errTestDecode = errors.New("decode blew up")
|
||||||
|
|
||||||
|
// currentIndex == -1 against a non-empty queue is a state this
|
||||||
|
// package produces on purpose: onQueueExhausted(false) sets it and
|
||||||
|
// deliberately leaves the finished track loaded in the player, so it
|
||||||
|
// stays on the now-playing bar. Pressing play from there and letting
|
||||||
|
// it finish re-enters OnPlaybackFinished with exactly that pair --
|
||||||
|
// which used to index q.tracks[-1] and panic, on a goroutine
|
||||||
|
// dispatched from the audio callback with no caller to recover it.
|
||||||
|
func TestFinishedWithNoCurrentTrackDoesNotPanic(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
index int
|
||||||
|
}{
|
||||||
|
{"exhausted queue leaves -1", -1},
|
||||||
|
{"index past the end", 3},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
q, _, _ := setupRecordedQueue(t)
|
||||||
|
q.tracks = []Track{
|
||||||
|
{FilePath: "/a.mp3"},
|
||||||
|
{FilePath: "/b.mp3"},
|
||||||
|
}
|
||||||
|
q.currentIndex = tt.index
|
||||||
|
|
||||||
|
// The assertion is that this returns at all.
|
||||||
|
q.OnPlaybackFinished(nil)
|
||||||
|
|
||||||
|
if q.currentIndex != tt.index {
|
||||||
|
t.Errorf(
|
||||||
|
"an out-of-range index was acted on: %d became %d",
|
||||||
|
tt.index, q.currentIndex,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A track that broke mid-playback is not a track that was listened
|
||||||
|
// to. The player cannot say so itself -- the metadata is here -- so
|
||||||
|
// it hands the reason over and this is where it becomes a
|
||||||
|
// PlaybackFailed rather than a silent auto-advance.
|
||||||
|
func TestAFailedTrackIsReportedAndNotCountedAsAPlay(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
q, _, rec := setupRecordedQueue(t)
|
||||||
|
q.tracks = []Track{
|
||||||
|
{FilePath: "/a.mp3", Title: "A", AudioFileID: 1},
|
||||||
|
{FilePath: "/b.mp3", Title: "B", AudioFileID: 2},
|
||||||
|
}
|
||||||
|
q.currentIndex = 0
|
||||||
|
|
||||||
|
q.OnPlaybackFinished(errTestDecode)
|
||||||
|
|
||||||
|
if _, ok := rec.Last(events.PlaybackFailed); !ok {
|
||||||
|
t.Errorf(
|
||||||
|
"a track that failed mid-playback told the user nothing; "+
|
||||||
|
"got %v",
|
||||||
|
rec.Names(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,18 +1,45 @@
|
|||||||
package queue
|
package queue
|
||||||
|
|
||||||
// OnPlaybackFinished is called when a track finishes playing naturally.
|
// OnPlaybackFinished is called when a track stops streaming. This
|
||||||
// This drives the auto-advance behavior and records the play.
|
// drives the auto-advance behavior and records the play.
|
||||||
func (q *Queue) OnPlaybackFinished() {
|
//
|
||||||
|
// srcErr says why the track stopped: nil for one that reached its
|
||||||
|
// end, non-nil for one that broke partway through. The player cannot
|
||||||
|
// tell the user which, because the metadata lives here -- so a failure
|
||||||
|
// is reported as PlaybackFailed and *not* recorded as a play, while
|
||||||
|
// the advance happens either way. Before this, a file that failed
|
||||||
|
// mid-track advanced in silence and was counted as listened to.
|
||||||
|
//
|
||||||
|
//wails:ignore // internal wiring, not part of the app's IPC surface.
|
||||||
|
func (q *Queue) OnPlaybackFinished(srcErr error) {
|
||||||
q.mu.Lock()
|
q.mu.Lock()
|
||||||
|
|
||||||
if len(q.tracks) == 0 {
|
// currentIndex is -1 whenever the queue has been exhausted, and
|
||||||
|
// onQueueExhausted deliberately leaves the finished track loaded
|
||||||
|
// in the player -- so a natural finish can re-enter here against a
|
||||||
|
// queue that is not empty and an index that is not valid. Every
|
||||||
|
// other path in this package bounds-checks before indexing; this
|
||||||
|
// one panicked, on a goroutine with no caller to recover it.
|
||||||
|
if q.currentIndex < 0 || q.currentIndex >= len(q.tracks) {
|
||||||
q.mu.Unlock()
|
q.mu.Unlock()
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Capture the track that just finished before advancing.
|
// Capture the track that just finished before advancing.
|
||||||
finishedID := q.tracks[q.currentIndex].AudioFileID
|
finished := q.tracks[q.currentIndex]
|
||||||
|
finishedID := finished.AudioFileID
|
||||||
|
|
||||||
|
if srcErr != nil {
|
||||||
|
q.emitPlaybackFailed(finished, srcErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A track that broke was not listened to.
|
||||||
|
recordFinished := func() {
|
||||||
|
if srcErr == nil {
|
||||||
|
q.recordPlay(finishedID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Repeat One: replay the current track.
|
// Repeat One: replay the current track.
|
||||||
if q.repeatMode == RepeatOne {
|
if q.repeatMode == RepeatOne {
|
||||||
@@ -21,7 +48,7 @@ func (q *Queue) OnPlaybackFinished() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
q.mu.Unlock()
|
q.mu.Unlock()
|
||||||
q.recordPlay(finishedID)
|
recordFinished()
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -31,7 +58,7 @@ func (q *Queue) OnPlaybackFinished() {
|
|||||||
// Queue exhausted — this is the extension point for a future fallback playlist.
|
// Queue exhausted — this is the extension point for a future fallback playlist.
|
||||||
q.onQueueExhausted(false)
|
q.onQueueExhausted(false)
|
||||||
q.mu.Unlock()
|
q.mu.Unlock()
|
||||||
q.recordPlay(finishedID)
|
recordFinished()
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -44,12 +71,12 @@ func (q *Queue) OnPlaybackFinished() {
|
|||||||
if !q.playCurrentOrSkip(true, q.nextIndex) {
|
if !q.playCurrentOrSkip(true, q.nextIndex) {
|
||||||
q.onQueueExhausted(false)
|
q.onQueueExhausted(false)
|
||||||
q.mu.Unlock()
|
q.mu.Unlock()
|
||||||
q.recordPlay(finishedID)
|
recordFinished()
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
q.emitIndexChanged()
|
q.emitIndexChanged()
|
||||||
q.mu.Unlock()
|
q.mu.Unlock()
|
||||||
q.recordPlay(finishedID)
|
recordFinished()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ func TestPlaybackFailed_AutoAdvanceSkipsPastIt(t *testing.T) {
|
|||||||
|
|
||||||
// The first track finished: auto-advance lands on the missing
|
// The first track finished: auto-advance lands on the missing
|
||||||
// file and must step over it rather than stopping dead.
|
// file and must step over it rather than stopping dead.
|
||||||
q.OnPlaybackFinished()
|
q.OnPlaybackFinished(nil)
|
||||||
|
|
||||||
if got := q.GetState().CurrentIndex; got != 2 {
|
if got := q.GetState().CurrentIndex; got != 2 {
|
||||||
t.Errorf("currentIndex after skipping: got %d, want 2", got)
|
t.Errorf("currentIndex after skipping: got %d, want 2", got)
|
||||||
@@ -183,7 +183,7 @@ func TestQueueExhausted_KeepsTheFinishedTrackLoaded(t *testing.T) {
|
|||||||
|
|
||||||
q.SetQueue(paths, 0, false, Source{})
|
q.SetQueue(paths, 0, false, Source{})
|
||||||
q.Play()
|
q.Play()
|
||||||
q.OnPlaybackFinished()
|
q.OnPlaybackFinished(nil)
|
||||||
|
|
||||||
if q.GetState().CurrentIndex != -1 {
|
if q.GetState().CurrentIndex != -1 {
|
||||||
t.Errorf(
|
t.Errorf(
|
||||||
|
|||||||
+16
-6
@@ -7,12 +7,22 @@ which is what lets Obtainium poll a plain URL with no token. It also
|
|||||||
attaches the same file to the Gitea release, which is what a person
|
attaches the same file to the Gitea release, which is what a person
|
||||||
looking at the release page downloads.
|
looking at the release page downloads.
|
||||||
|
|
||||||
**Tags are not pushed by hand any more.** `.gitea/workflows/release.yml`
|
**Tags are not pushed by hand any more, but releasing is a decision.**
|
||||||
reads the Conventional Commits on every merge to `main`, decides the
|
`.gitea/workflows/release.yml` reads the Conventional Commits since the
|
||||||
version, and pushes the tag this workflow is keyed on — so releasing the
|
last tag, decides the version, and pushes the tag this workflow is keyed
|
||||||
APK means merging a `fix:` or `feat:` commit, not running `git tag`. The
|
on — so releasing the APK means **running that workflow**, not running
|
||||||
`workflow_dispatch` path below remains, for rebuilding a tag that already
|
`git tag`. It has no push trigger: merging a `fix:` or `feat:` used to
|
||||||
exists.
|
be enough and produced a version per merged PR (issue #115). Run it with
|
||||||
|
`dry_run` first to see what the accumulated commits would ship. The
|
||||||
|
`workflow_dispatch` path below is a different thing and remains, for
|
||||||
|
rebuilding a tag that already exists.
|
||||||
|
|
||||||
|
**A prerelease tag is skipped here**, cleanly. This workflow triggers on
|
||||||
|
`v*`, which matches `v0.4.0-beta.1`, and it is the one where that would
|
||||||
|
hurt most: the APK goes to the credential-free generic registry that
|
||||||
|
Obtainium polls, and the `versionCode` maths below splits on dots — it
|
||||||
|
would read `1` out of `0-beta` and produce a wrong number rather than a
|
||||||
|
failed build.
|
||||||
|
|
||||||
## The 1.x installs cannot be upgraded to 0.0.x
|
## The 1.x installs cannot be upgraded to 0.0.x
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import { test, expect } from '../support/fixtures.js';
|
import { test, expect } from '../support/fixtures.js';
|
||||||
import type { Page } from '@playwright/test';
|
import type { Page } from '@playwright/test';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How far the scroll test scrolls. One constant, because the guard and
|
||||||
|
* the assertion have to agree about it — they did not, which is #133.
|
||||||
|
*/
|
||||||
|
const SCROLL_TARGET = 80;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Plan 007 phase 5: expanding an album shows its tracks.
|
* Plan 007 phase 5: expanding an album shows its tracks.
|
||||||
*
|
*
|
||||||
@@ -104,20 +110,27 @@ test.describe('the album dropdown', () => {
|
|||||||
await app.setViewportSize({ width: 900, height: 600 });
|
await app.setViewportSize({ width: 900, height: 600 });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await expect.poll(() => scrollRange(app)).toMatchObject({
|
// Wait for the range the assertion below actually needs, not for
|
||||||
scrollable: true,
|
// "scrollable at all" (#133). The guard used to be
|
||||||
overflowY: 'auto',
|
// `scrollHeight > clientHeight + 40` while the next line asks to
|
||||||
});
|
// reach 80, so any range in 41-79 satisfied it and could not
|
||||||
|
// satisfy the assertion — and the grid passes through exactly
|
||||||
|
// that while it settles, because it recomputes its columns after
|
||||||
|
// the resize rather than during it. The settled range here is
|
||||||
|
// 330, so this waits rather than weakening anything.
|
||||||
|
await expect
|
||||||
|
.poll(() => scrollRange(app))
|
||||||
|
.toMatchObject({ room: true, overflowY: 'auto' });
|
||||||
|
|
||||||
await app.evaluate(() => {
|
await app.evaluate((target) => {
|
||||||
const sc = document
|
const sc = document
|
||||||
.querySelector('cover-grid')
|
.querySelector('cover-grid')
|
||||||
?.shadowRoot?.querySelector('.grid-scroll-container');
|
?.shadowRoot?.querySelector('.grid-scroll-container');
|
||||||
|
|
||||||
if (sc) sc.scrollTop = 80;
|
if (sc) sc.scrollTop = target;
|
||||||
});
|
}, SCROLL_TARGET);
|
||||||
|
|
||||||
expect(await scrollTop(app)).toBe(80);
|
expect(await scrollTop(app)).toBe(SCROLL_TARGET);
|
||||||
|
|
||||||
// And the dropdown it opens is on screen, wherever the manager
|
// And the dropdown it opens is on screen, wherever the manager
|
||||||
// decides that leaves the scroll. It is *not* "the position is
|
// decides that leaves the scroll. It is *not* "the position is
|
||||||
@@ -250,16 +263,19 @@ async function closeDropdown(app: Page): Promise<void> {
|
|||||||
|
|
||||||
/** Whether the grid can scroll at all, which decides if a probe can move. */
|
/** Whether the grid can scroll at all, which decides if a probe can move. */
|
||||||
async function scrollRange(app: Page) {
|
async function scrollRange(app: Page) {
|
||||||
return app.evaluate(() => {
|
return app.evaluate((target) => {
|
||||||
const sc = document
|
const sc = document
|
||||||
.querySelector('cover-grid')
|
.querySelector('cover-grid')
|
||||||
?.shadowRoot?.querySelector('.grid-scroll-container');
|
?.shadowRoot?.querySelector('.grid-scroll-container');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
scrollable: !!sc && sc.scrollHeight > sc.clientHeight + 40,
|
// `room` is the precondition of the assertion that follows it:
|
||||||
|
// enough range to actually reach the target. A threshold below
|
||||||
|
// what the caller depends on is not a guard.
|
||||||
|
room: !!sc && sc.scrollHeight - sc.clientHeight >= target,
|
||||||
overflowY: sc ? getComputedStyle(sc).overflowY : '',
|
overflowY: sc ? getComputedStyle(sc).overflowY : '',
|
||||||
};
|
};
|
||||||
});
|
}, SCROLL_TARGET);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function scrollTop(app: Page): Promise<number> {
|
async function scrollTop(app: Page): Promise<number> {
|
||||||
|
|||||||
@@ -26,6 +26,12 @@ const MIN_VIEWPORT = { width: 800, height: 600 };
|
|||||||
const VIEWPORTS = [
|
const VIEWPORTS = [
|
||||||
{ name: '1440×900', width: 1440, height: 900 },
|
{ name: '1440×900', width: 1440, height: 900 },
|
||||||
{ name: '1024×768', width: 1024, height: 768 },
|
{ name: '1024×768', width: 1024, height: 768 },
|
||||||
|
// Not the minimum, and that is the point (#24). The sidebar collapses
|
||||||
|
// to icons *below* 900, so the main panel is 843px at 899 and 700px
|
||||||
|
// at 900 — the narrowest content area any desktop width produces is
|
||||||
|
// here, not at the enforced floor. A list that stopped at the minimum
|
||||||
|
// was missing its own worst case.
|
||||||
|
{ name: '900×600 (the widest sidebar, so the narrowest content)', width: 900, height: 600 },
|
||||||
{ name: `the minimum (${MIN_VIEWPORT.width}×${MIN_VIEWPORT.height})`, ...MIN_VIEWPORT },
|
{ name: `the minimum (${MIN_VIEWPORT.width}×${MIN_VIEWPORT.height})`, ...MIN_VIEWPORT },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,187 @@
|
|||||||
|
import { test, expect } from '../support/fixtures.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #24 — the queue panel does not take the page's width away from it.
|
||||||
|
*
|
||||||
|
* The panel is `flex-shrink: 0` in the flow of `.content-area`, so an
|
||||||
|
* open queue used to be paid for by the main panel. Measured on
|
||||||
|
* Playlists before the fix:
|
||||||
|
*
|
||||||
|
* | viewport | main panel |
|
||||||
|
* |---|---|
|
||||||
|
* | 900×600 | 379px — all three header actions clipped |
|
||||||
|
* | 390×780 | 69px |
|
||||||
|
* | 320×600 | **0px** |
|
||||||
|
*
|
||||||
|
* **900×600 is the worst desktop case, not the 800×600 minimum**, and
|
||||||
|
* that is the trap this file exists to keep closed: the sidebar
|
||||||
|
* collapses to icons *below* 900, so the main panel is 843px at 899 and
|
||||||
|
* 700px at 900. A spec that checks "the minimum" and stops has not
|
||||||
|
* checked the worst case — which is what every viewport list in this
|
||||||
|
* suite did before this.
|
||||||
|
*
|
||||||
|
* These assert the *content's* width rather than the panel's mode
|
||||||
|
* wherever they can, because the mode is the mechanism and the width is
|
||||||
|
* the complaint.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** The bands from plan 018's size matrix, plus the pixel above the collapse. */
|
||||||
|
const BANDS = [
|
||||||
|
{ name: 'a wide desktop (1280×800)', width: 1280, height: 800, inline: true },
|
||||||
|
{ name: 'the default window (1100×720)', width: 1100, height: 720, inline: true },
|
||||||
|
{ name: 'a laptop (1024×768)', width: 1024, height: 768, inline: true },
|
||||||
|
{ name: 'the worst desktop width (900×600)', width: 900, height: 600, inline: false },
|
||||||
|
{ name: 'the enforced minimum (800×600)', width: 800, height: 600, inline: false },
|
||||||
|
{ name: 'a phone (390×780)', width: 390, height: 780, inline: false },
|
||||||
|
{ name: '400% zoom (320×600)', width: 320, height: 600, inline: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How much room the content has, and whether the shell needs scrolling
|
||||||
|
* to reach any of itself.
|
||||||
|
*/
|
||||||
|
const shellGeometry = (page: import('@playwright/test').Page) =>
|
||||||
|
page.evaluate(() => {
|
||||||
|
const main = document.querySelector('#main-content')!.getBoundingClientRect();
|
||||||
|
const panel = document.querySelector('#queue-panel')!;
|
||||||
|
|
||||||
|
return {
|
||||||
|
mainWidth: Math.round(main.width),
|
||||||
|
overlay: panel.hasAttribute('overlay'),
|
||||||
|
open: panel.hasAttribute('open'),
|
||||||
|
bodyScrollWidth: document.body.scrollWidth,
|
||||||
|
bodyClientWidth: document.body.clientWidth,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
async function openQueue(page: import('@playwright/test').Page) {
|
||||||
|
const toggle = page.locator('#queue-button');
|
||||||
|
|
||||||
|
if ((await toggle.getAttribute('aria-expanded')) !== 'true') {
|
||||||
|
await toggle.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
await expect(toggle).toHaveAttribute('aria-expanded', 'true');
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('an open queue leaves the content its width', () => {
|
||||||
|
for (const band of BANDS) {
|
||||||
|
test(`at ${band.name}`, async ({ app }) => {
|
||||||
|
await app.setViewportSize({ width: band.width, height: band.height });
|
||||||
|
await openQueue(app);
|
||||||
|
|
||||||
|
// The mode is settled by a ResizeObserver, so poll rather than
|
||||||
|
// read once: a single read races the resize and reports the
|
||||||
|
// previous viewport's answer.
|
||||||
|
await expect
|
||||||
|
.poll(async () => (await shellGeometry(app)).overlay)
|
||||||
|
.toBe(!band.inline);
|
||||||
|
|
||||||
|
const geo = await shellGeometry(app);
|
||||||
|
|
||||||
|
// The floor is the point of the whole issue. Inline, the queue is
|
||||||
|
// affordable and the content keeps the rest; as an overlay the
|
||||||
|
// content keeps *everything*, which is what makes 0px at 320
|
||||||
|
// impossible rather than merely unlikely.
|
||||||
|
expect(geo.mainWidth).toBeGreaterThanOrEqual(320);
|
||||||
|
|
||||||
|
if (!band.inline) {
|
||||||
|
expect(geo.mainWidth).toBeGreaterThanOrEqual(
|
||||||
|
Math.min(band.width, 320),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// And opening the queue must not make the shell overflow.
|
||||||
|
expect(geo.bodyScrollWidth).toBeLessThanOrEqual(geo.bodyClientWidth);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('an overlaid queue says it is over the content', () => {
|
||||||
|
test.beforeEach(async ({ app }) => {
|
||||||
|
await app.setViewportSize({ width: 900, height: 600 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('draws a scrim and closes when it is clicked', async ({ app }) => {
|
||||||
|
await openQueue(app);
|
||||||
|
|
||||||
|
const panel = app.locator('#queue-panel');
|
||||||
|
|
||||||
|
await expect(panel).toHaveAttribute('overlay', '');
|
||||||
|
|
||||||
|
// The scrim is `aria-hidden` on purpose — it is a dismissal target,
|
||||||
|
// and the named routes out are the close button and Escape — so it
|
||||||
|
// is located structurally rather than by role.
|
||||||
|
await panel.evaluate((el) =>
|
||||||
|
el.shadowRoot!.querySelector<HTMLElement>('.scrim')!.click(),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(app.locator('#queue-button')).toHaveAttribute(
|
||||||
|
'aria-expanded',
|
||||||
|
'false',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `getByRole`, not a shadow-root query: this repo has shipped a
|
||||||
|
* nameless control three times, and a drawer with a scrim is exactly
|
||||||
|
* the shape that grows a fourth.
|
||||||
|
*/
|
||||||
|
test('offers a named close button', async ({ app }) => {
|
||||||
|
await openQueue(app);
|
||||||
|
|
||||||
|
const close = app.getByRole('button', { name: 'Close queue' });
|
||||||
|
|
||||||
|
await expect(close).toBeVisible();
|
||||||
|
await close.click();
|
||||||
|
|
||||||
|
await expect(app.locator('#queue-button')).toHaveAttribute(
|
||||||
|
'aria-expanded',
|
||||||
|
'false',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('closes on Escape and gives focus back to the toggle', async ({
|
||||||
|
app,
|
||||||
|
}) => {
|
||||||
|
const toggle = app.locator('#queue-button');
|
||||||
|
|
||||||
|
await toggle.focus();
|
||||||
|
await toggle.click();
|
||||||
|
await expect(toggle).toHaveAttribute('aria-expanded', 'true');
|
||||||
|
|
||||||
|
await app.keyboard.press('Escape');
|
||||||
|
|
||||||
|
await expect(toggle).toHaveAttribute('aria-expanded', 'false');
|
||||||
|
await expect(toggle).toBeFocused();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The inline panel is the mode that already worked, and the one every
|
||||||
|
* other queue spec is written against. It keeps its resize handle and
|
||||||
|
* gains none of the overlay's chrome.
|
||||||
|
*/
|
||||||
|
test.describe('a wide window keeps the queue beside the content', () => {
|
||||||
|
test('no scrim, no close button, and the content is narrower', async ({
|
||||||
|
app,
|
||||||
|
}) => {
|
||||||
|
await app.setViewportSize({ width: 1280, height: 800 });
|
||||||
|
|
||||||
|
const widthWithoutQueue = (await shellGeometry(app)).mainWidth;
|
||||||
|
|
||||||
|
await openQueue(app);
|
||||||
|
|
||||||
|
await expect(app.locator('#queue-panel')).not.toHaveAttribute(
|
||||||
|
'overlay',
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
|
||||||
|
const geo = await shellGeometry(app);
|
||||||
|
|
||||||
|
expect(geo.mainWidth).toBeLessThan(widthWithoutQueue);
|
||||||
|
await expect(
|
||||||
|
app.getByRole('button', { name: 'Close queue' }),
|
||||||
|
).toHaveCount(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -7,6 +7,7 @@ export {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
|
AlbumMatchView,
|
||||||
AlignmentView,
|
AlignmentView,
|
||||||
ApplyResultView,
|
ApplyResultView,
|
||||||
CandidateView,
|
CandidateView,
|
||||||
|
|||||||
@@ -1,6 +1,61 @@
|
|||||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||||
// This file is automatically generated. DO NOT EDIT
|
// This file is automatically generated. DO NOT EDIT
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AlbumMatchView is "the autotagger already has a confident match for
|
||||||
|
* the album you are looking at".
|
||||||
|
*
|
||||||
|
* It is deliberately not a score. The album page renders a suggestion,
|
||||||
|
* and a suggestion has to be actionable: which release, what it is
|
||||||
|
* called, and whether acting on it here would do the whole album or
|
||||||
|
* only part of it.
|
||||||
|
*/
|
||||||
|
export interface AlbumMatchView {
|
||||||
|
/**
|
||||||
|
* GroupKey is the tagging group the actions operate on.
|
||||||
|
*/
|
||||||
|
"groupKey": string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recommendation is the tier, as a string, for a caller that
|
||||||
|
* wants to render the strength rather than trust the filter.
|
||||||
|
*/
|
||||||
|
"recommendation": string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Score is the top candidate's raw score, 0..1.
|
||||||
|
*/
|
||||||
|
"score": number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ReleaseMBID is the release Apply would write.
|
||||||
|
*/
|
||||||
|
"releaseMbid": string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Title and ArtistCredit name that release, so the banner can say
|
||||||
|
* what it is offering rather than "a match".
|
||||||
|
*/
|
||||||
|
"title": string;
|
||||||
|
"artistCredit": string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TrackCount is the group's local track count.
|
||||||
|
*/
|
||||||
|
"trackCount": number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GroupCount is how many tagging groups this album spans.
|
||||||
|
*
|
||||||
|
* More than one means a multi-disc album (one group per disc), and
|
||||||
|
* it is the reason this is a field rather than an implementation
|
||||||
|
* detail: applying "the album" from a single button would retag
|
||||||
|
* one disc of three and leave the folder holding a mix of old and
|
||||||
|
* new tags. The caller offers review instead.
|
||||||
|
*/
|
||||||
|
"groupCount": number;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AlignmentView mirrors autotag.TrackAlignment. LocalIndex of -1
|
* AlignmentView mirrors autotag.TrackAlignment. LocalIndex of -1
|
||||||
* means "candidate has this track, folder doesn't" (status=missing).
|
* means "candidate has this track, folder doesn't" (status=missing).
|
||||||
|
|||||||
@@ -160,6 +160,39 @@ export function ListPendingFolders(libraryID: number): $CancellablePromise<$mode
|
|||||||
return $Call.ByID(617511590, libraryID);
|
return $Call.ByID(617511590, libraryID);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MatchForAlbum answers "does the autotagger have something confident
|
||||||
|
* to say about this album", for the album detail page.
|
||||||
|
*
|
||||||
|
* Three things about it are load-bearing.
|
||||||
|
*
|
||||||
|
* **It costs no MusicBrainz request.** Everything it needs is already
|
||||||
|
* on disk: `tagging_items` carries the top score and release from the
|
||||||
|
* background prefetch, and `tagging_candidates` durably holds the
|
||||||
|
* scored list. The rate limiters here are shared with every page the
|
||||||
|
* user can open, so a lookup that fires on page load must not join
|
||||||
|
* that queue — which also means this returns nothing for a folder
|
||||||
|
* nobody has scored yet, rather than scoring it now. That is the
|
||||||
|
* right trade: the prefetch will get to it, and a page that silently
|
||||||
|
* spends a minute of somebody's MusicBrainz budget to draw a banner
|
||||||
|
* is worse than a page that says nothing.
|
||||||
|
*
|
||||||
|
* **The tier is computed, not read.** `tagging_items.score` is the raw
|
||||||
|
* number and `Recommend` is what turns it into a claim — capping it
|
||||||
|
* for an ambiguous runner-up, an incomplete alignment or a folder too
|
||||||
|
* small to corroborate itself. Filtering on the raw score would
|
||||||
|
* promise confidence the scorer had explicitly withheld.
|
||||||
|
*
|
||||||
|
* **Nothing is said about an album the user has already answered
|
||||||
|
* for.** Only a `pending` group qualifies: `confirmed` covers both a
|
||||||
|
* finished apply and an explicit "leave as is", and `skipped` is the
|
||||||
|
* user saying not now. Re-offering either is nagging, and "leave as
|
||||||
|
* is" would be actively wrong to argue with.
|
||||||
|
*/
|
||||||
|
export function MatchForAlbum(albumID: number): $CancellablePromise<$models.AlbumMatchView | null> {
|
||||||
|
return $Call.ByID(514173221, albumID);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* RetagGroup flips a group back to 'pending' so the user can
|
* RetagGroup flips a group back to 'pending' so the user can
|
||||||
* re-review after an apply or skip. Drops the durably-cached
|
* re-review after an apply or skip. Drops the durably-cached
|
||||||
|
|||||||
@@ -431,8 +431,17 @@ export interface TopResult {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Library status — populated from index cross-reference columns.
|
* Library status — populated from index cross-reference columns.
|
||||||
|
*
|
||||||
|
* LocalID is the one the cards read. It is the local row behind
|
||||||
|
* this entity — an album, a file, an artist — and it is set and
|
||||||
|
* cleared by a test against `audio_files`, so it means "there is
|
||||||
|
* something of mine here". InLibrary is written by the same pass
|
||||||
|
* but is a one-way ratchet the prune can only clear alongside a
|
||||||
|
* local id, so it is the weaker of the two and stays for scoring
|
||||||
|
* (`fwInLibrary`), which is where an approximate answer is fine.
|
||||||
*/
|
*/
|
||||||
"inLibrary": boolean;
|
"inLibrary": boolean;
|
||||||
|
"localId"?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -98,6 +98,26 @@ export function GetAlbumsByArtist(artist: string, libraryID: number): $Cancellab
|
|||||||
return $Call.ByID(1456840721, artist, libraryID);
|
return $Call.ByID(1456840721, artist, libraryID);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GetAlbumsCompleteness answers the same question for a screenful of
|
||||||
|
* albums in one query, keyed by album id.
|
||||||
|
*
|
||||||
|
* A card grid asks this about every card that has a local album behind
|
||||||
|
* it, and one query per card is how a grid of fifty albums becomes
|
||||||
|
* fifty round trips. The answer matters there for the reason it
|
||||||
|
* matters on the album page: an album held 9 tracks of 12 has to show
|
||||||
|
* the count, and a bare tick saying "in your library" is the complaint
|
||||||
|
* this whole rule came from.
|
||||||
|
*
|
||||||
|
* An album with no row in the result is one with no files, and it is
|
||||||
|
* absent rather than zeroed — "I have none of this" and "I have no
|
||||||
|
* idea" are the same third state `Known` exists to keep apart, and a
|
||||||
|
* caller reading a missing key gets nothing rather than a confident 0.
|
||||||
|
*/
|
||||||
|
export function GetAlbumsCompleteness(albumIDs: number[] | null): $CancellablePromise<{ [_ in `${number}`]?: $models.AlbumCompleteness } | null> {
|
||||||
|
return $Call.ByID(531636827, albumIDs);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GetAllLibrariesWithTrackCounts lists the libraries and their sizes.
|
* GetAllLibrariesWithTrackCounts lists the libraries and their sizes.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -113,14 +113,6 @@ export function Next(): $CancellablePromise<void> {
|
|||||||
return $Call.ByID(1968784044);
|
return $Call.ByID(1968784044);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* OnPlaybackFinished is called when a track finishes playing naturally.
|
|
||||||
* This drives the auto-advance behavior and records the play.
|
|
||||||
*/
|
|
||||||
export function OnPlaybackFinished(): $CancellablePromise<void> {
|
|
||||||
return $Call.ByID(2184869763);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Play handles a play request by either resuming the current track or
|
* Play handles a play request by either resuming the current track or
|
||||||
* starting playback from the beginning of the queue. When a track is
|
* starting playback from the beginning of the queue. When a track is
|
||||||
|
|||||||
@@ -231,6 +231,14 @@ body div.sidebar {
|
|||||||
display: flex;
|
display: flex;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
contain: layout style;
|
contain: layout style;
|
||||||
|
|
||||||
|
/* The containing block for the queue panel's overlay mode (plan
|
||||||
|
018, #24), which spans this box rather than taking width from
|
||||||
|
the main panel beside it. `contain: layout` already establishes
|
||||||
|
one; this says so on purpose, so that removing the containment
|
||||||
|
for a paint reason does not silently reparent the overlay to the
|
||||||
|
viewport. */
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.main-panel {
|
.main-panel {
|
||||||
|
|||||||
+4
-1
@@ -39,7 +39,10 @@
|
|||||||
<audio-player></audio-player>
|
<audio-player></audio-player>
|
||||||
<button aria-label="Toggle queue" aria-controls="queue-panel" aria-expanded="false"
|
<button aria-label="Toggle queue" aria-controls="queue-panel" aria-expanded="false"
|
||||||
id="queue-button">
|
id="queue-button">
|
||||||
<wa-icon name="list"></wa-icon>
|
<!-- ICON_QUEUE in src/utils/icon-language.ts, written out
|
||||||
|
because this file has no module scope. It was `list`,
|
||||||
|
which is the Playlists destination's icon. -->
|
||||||
|
<wa-icon name="bars-staggered"></wa-icon>
|
||||||
</button>
|
</button>
|
||||||
</footer>
|
</footer>
|
||||||
<!-- The phone's primary navigation, hidden above 600px by
|
<!-- The phone's primary navigation, hidden above 600px by
|
||||||
|
|||||||
@@ -308,6 +308,18 @@ async function handleNavigate(
|
|||||||
deactivateView(currentViewEl);
|
deactivateView(currentViewEl);
|
||||||
}
|
}
|
||||||
target.classList.remove('view-hidden');
|
target.classList.remove('view-hidden');
|
||||||
|
|
||||||
|
// A primary view is cached, so there is no construction to
|
||||||
|
// hand a payload to the way a detail view gets one below. The
|
||||||
|
// one navigation that carries something is the album page's
|
||||||
|
// "Review in Autotag", which has to land on *that* album: the
|
||||||
|
// request goes on as an attribute and `autotag-view` consumes
|
||||||
|
// it (removes it) once acted on, or every later visit would
|
||||||
|
// reopen a folder the user finished with long ago.
|
||||||
|
if (view === 'autotag' && typeof detail.groupKey === 'string') {
|
||||||
|
target.setAttribute('group-key', detail.groupKey);
|
||||||
|
}
|
||||||
|
|
||||||
// A freshly created view was appended hidden, so it did not
|
// A freshly created view was appended hidden, so it did not
|
||||||
// self-activate on connection; a cached one was deactivated on
|
// self-activate on connection; a cached one was deactivated on
|
||||||
// the way out. Either way this is the call that starts it.
|
// the way out. Either way this is the call that starts it.
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--! Font Awesome Free 7.3.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2026 Fonticons, Inc. --><path fill="currentColor" d="M0 96C0 78.3 14.3 64 32 64l384 0c17.7 0 32 14.3 32 32s-14.3 32-32 32L32 128C14.3 128 0 113.7 0 96zM64 256c0-17.7 14.3-32 32-32l384 0c17.7 0 32 14.3 32 32s-14.3 32-32 32L96 288c-17.7 0-32-14.3-32-32zM448 416c0 17.7-14.3 32-32 32L32 448c-17.7 0-32-14.3-32-32s14.3-32 32-32l384 0c17.7 0 32 14.3 32 32z"/></svg>
|
||||||
|
After Width: | Height: | Size: 609 B |
@@ -37,6 +37,10 @@ import type WaPopup from '@awesome.me/webawesome/dist/components/popup/popup.js'
|
|||||||
import '@awesome.me/webawesome/dist/components/dropdown-item/dropdown-item.js';
|
import '@awesome.me/webawesome/dist/components/dropdown-item/dropdown-item.js';
|
||||||
import '@components/playlist-picker/playlist-picker.js';
|
import '@components/playlist-picker/playlist-picker.js';
|
||||||
import { dict, list } from '@utils/binding';
|
import { dict, list } from '@utils/binding';
|
||||||
|
import {
|
||||||
|
ICON_PLAYLIST,
|
||||||
|
ICON_QUEUE,
|
||||||
|
} from '@utils/icon-language';
|
||||||
|
|
||||||
/** Pixels to change card width per scroll tick. */
|
/** Pixels to change card width per scroll tick. */
|
||||||
const ZOOM_STEP = 16;
|
const ZOOM_STEP = 16;
|
||||||
@@ -1371,7 +1375,7 @@ export class ArtistsView
|
|||||||
>
|
>
|
||||||
<wa-icon
|
<wa-icon
|
||||||
slot="icon"
|
slot="icon"
|
||||||
name="plus"
|
name=${ICON_QUEUE}
|
||||||
></wa-icon>
|
></wa-icon>
|
||||||
Add to Queue
|
Add to Queue
|
||||||
</wa-dropdown-item>
|
</wa-dropdown-item>
|
||||||
@@ -1407,7 +1411,7 @@ export class ArtistsView
|
|||||||
>
|
>
|
||||||
<wa-icon
|
<wa-icon
|
||||||
slot="icon"
|
slot="icon"
|
||||||
name="plus"
|
name=${ICON_PLAYLIST}
|
||||||
></wa-icon>
|
></wa-icon>
|
||||||
Add to Playlist
|
Add to Playlist
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -1315,13 +1315,40 @@ export class AutotagView extends ViewLifecycleMixin(LitElement) {
|
|||||||
// the page only needs the folder list, which is local and may
|
// the page only needs the folder list, which is local and may
|
||||||
// have moved while the page was away.
|
// have moved while the page was away.
|
||||||
if (this.queueStarted) {
|
if (this.queueStarted) {
|
||||||
void this.loadFolders();
|
void this.loadFolders().then(() => this.openRequestedFolder());
|
||||||
} else {
|
} else {
|
||||||
this.queueStarted = true;
|
this.queueStarted = true;
|
||||||
void this.startQueue();
|
void this.startQueue().then(() => this.openRequestedFolder());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open the folder somebody navigated here to look at.
|
||||||
|
*
|
||||||
|
* The album page's "Review in Autotag" has to land on *that*
|
||||||
|
* album. The queue is sorted by score so the intended folder is
|
||||||
|
* often near the top, but "often" is a link that sometimes opens
|
||||||
|
* the wrong album, which is worse than no link.
|
||||||
|
*
|
||||||
|
* It is an attribute rather than a property because this is a
|
||||||
|
* **cached primary view**: `index.ts` creates it once and reuses
|
||||||
|
* it, so there is no construction to pass a value to. Which is
|
||||||
|
* also why the request is *consumed* — the attribute is removed
|
||||||
|
* once acted on, or every later visit to Autotag would reopen an
|
||||||
|
* album the user finished with three navigations ago.
|
||||||
|
*/
|
||||||
|
private openRequestedFolder(): void {
|
||||||
|
const requested = this.getAttribute('group-key');
|
||||||
|
|
||||||
|
if (!requested) return;
|
||||||
|
|
||||||
|
this.removeAttribute('group-key');
|
||||||
|
|
||||||
|
if (this.current?.groupKey === requested) return;
|
||||||
|
|
||||||
|
void this.selectFolder(requested);
|
||||||
|
}
|
||||||
|
|
||||||
protected override onViewDeactivate(): void {
|
protected override onViewDeactivate(): void {
|
||||||
this.unsubscribeLibraryStore?.();
|
this.unsubscribeLibraryStore?.();
|
||||||
this.unsubscribeLibraryStore = undefined;
|
this.unsubscribeLibraryStore = undefined;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type WaDrawer from '@awesome.me/webawesome/dist/components/drawer/drawer.
|
|||||||
import { designTokens } from '../../styles/tokens.css';
|
import { designTokens } from '../../styles/tokens.css';
|
||||||
import '../sidebar/app-sidebar.js';
|
import '../sidebar/app-sidebar.js';
|
||||||
import { nameDialog } from '@utils/name-dialog';
|
import { nameDialog } from '@utils/name-dialog';
|
||||||
|
import { ICON_PLAYLIST } from '@utils/icon-language';
|
||||||
|
|
||||||
type View = 'home' | 'albums' | 'tracks' | 'playlists';
|
type View = 'home' | 'albums' | 'tracks' | 'playlists';
|
||||||
|
|
||||||
@@ -138,7 +139,7 @@ export class BottomNav extends LitElement {
|
|||||||
{ id: 'home', label: 'Home', icon: 'house' },
|
{ id: 'home', label: 'Home', icon: 'house' },
|
||||||
{ id: 'albums', label: 'Albums', icon: 'compact-disc' },
|
{ id: 'albums', label: 'Albums', icon: 'compact-disc' },
|
||||||
{ id: 'tracks', label: 'Tracks', icon: 'music' },
|
{ id: 'tracks', label: 'Tracks', icon: 'music' },
|
||||||
{ id: 'playlists', label: 'Playlists', icon: 'list' },
|
{ id: 'playlists', label: 'Playlists', icon: ICON_PLAYLIST },
|
||||||
];
|
];
|
||||||
|
|
||||||
override connectedCallback() {
|
override connectedCallback() {
|
||||||
|
|||||||
@@ -81,23 +81,55 @@ export class ConfirmDialog extends LitElement {
|
|||||||
`,
|
`,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which question is on screen.
|
||||||
|
*
|
||||||
|
* This is a singleton reused for every confirmation in the app,
|
||||||
|
* and `wa-dialog` reports its close *asynchronously* — `open =
|
||||||
|
* false` starts an animation and `wa-hide` arrives after it. So a
|
||||||
|
* hide belonging to a question that has already been answered can
|
||||||
|
* land after the *next* question has opened, and cancel it: the
|
||||||
|
* user is asked something, the dialog vanishes on its own, and the
|
||||||
|
* call site is told they said no.
|
||||||
|
*
|
||||||
|
* The counter is what tells one question from the next. Every
|
||||||
|
* close bumps it, and the `wa-hide` handler carries the id its
|
||||||
|
* template was rendered with.
|
||||||
|
*/
|
||||||
|
private askSeq = 0;
|
||||||
|
|
||||||
/** Ask. Resolves true if the user went ahead. */
|
/** Ask. Resolves true if the user went ahead. */
|
||||||
ask(request: ConfirmRequest): Promise<boolean> {
|
ask(request: ConfirmRequest): Promise<boolean> {
|
||||||
this.close(false);
|
this.close(false);
|
||||||
|
|
||||||
|
const id = ++this.askSeq;
|
||||||
|
|
||||||
this.request = request;
|
this.request = request;
|
||||||
|
|
||||||
return new Promise<boolean>((resolve) => {
|
return new Promise<boolean>((resolve) => {
|
||||||
this.settle = resolve;
|
this.settle = resolve;
|
||||||
void this.updateComplete.then(() => {
|
void this.updateComplete.then(() => {
|
||||||
if (this.dialog) this.dialog.open = true;
|
// A third question could have arrived while this one
|
||||||
|
// was waiting for its own render.
|
||||||
|
if (this.askSeq === id && this.dialog) this.dialog.open = true;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private close(ok: boolean): void {
|
/**
|
||||||
|
* Settle the current question, if `id` still names it.
|
||||||
|
*
|
||||||
|
* The button handlers pass nothing and always mean the question on
|
||||||
|
* screen; only `wa-hide` carries an id, because only `wa-hide` can
|
||||||
|
* arrive late.
|
||||||
|
*/
|
||||||
|
private close(ok: boolean, id = this.askSeq): void {
|
||||||
|
if (id !== this.askSeq) return;
|
||||||
|
|
||||||
const settle = this.settle;
|
const settle = this.settle;
|
||||||
|
|
||||||
this.settle = null;
|
this.settle = null;
|
||||||
|
this.askSeq++;
|
||||||
|
|
||||||
if (this.dialog) this.dialog.open = false;
|
if (this.dialog) this.dialog.open = false;
|
||||||
this.request = null;
|
this.request = null;
|
||||||
@@ -118,11 +150,15 @@ export class ConfirmDialog extends LitElement {
|
|||||||
|
|
||||||
if (!request) return nothing;
|
if (!request) return nothing;
|
||||||
|
|
||||||
|
// Captured at render time, so the handler answers the question
|
||||||
|
// it was drawn for and not whichever one is up when it fires.
|
||||||
|
const id = this.askSeq;
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<wa-dialog
|
<wa-dialog
|
||||||
label=${request.title}
|
label=${request.title}
|
||||||
data-testid="confirm-dialog"
|
data-testid="confirm-dialog"
|
||||||
@wa-hide=${() => this.close(false)}
|
@wa-hide=${() => this.close(false, id)}
|
||||||
>
|
>
|
||||||
<p>${request.message}</p>
|
<p>${request.message}</p>
|
||||||
${request.impact
|
${request.impact
|
||||||
|
|||||||
@@ -76,6 +76,10 @@ import type {
|
|||||||
SortDirection,
|
SortDirection,
|
||||||
} from './cover-grid-types.js';
|
} from './cover-grid-types.js';
|
||||||
import { list } from '@utils/binding';
|
import { list } from '@utils/binding';
|
||||||
|
import {
|
||||||
|
ICON_PLAYLIST,
|
||||||
|
ICON_QUEUE,
|
||||||
|
} from '@utils/icon-language';
|
||||||
|
|
||||||
@customElement('cover-grid')
|
@customElement('cover-grid')
|
||||||
export class CoverGrid
|
export class CoverGrid
|
||||||
@@ -2123,7 +2127,7 @@ export class CoverGrid
|
|||||||
>
|
>
|
||||||
<wa-icon
|
<wa-icon
|
||||||
slot="icon"
|
slot="icon"
|
||||||
name="plus"
|
name=${ICON_QUEUE}
|
||||||
></wa-icon>
|
></wa-icon>
|
||||||
Add to Queue
|
Add to Queue
|
||||||
</wa-dropdown-item>
|
</wa-dropdown-item>
|
||||||
@@ -2156,7 +2160,7 @@ export class CoverGrid
|
|||||||
>
|
>
|
||||||
<wa-icon
|
<wa-icon
|
||||||
slot="icon"
|
slot="icon"
|
||||||
name="plus"
|
name=${ICON_PLAYLIST}
|
||||||
></wa-icon>
|
></wa-icon>
|
||||||
Add to Playlist
|
Add to Playlist
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { customElement, property, state, query } from 'lit/decorators.js';
|
|||||||
import { classMap } from 'lit/directives/class-map.js';
|
import { classMap } from 'lit/directives/class-map.js';
|
||||||
import { designTokens } from '../../styles/tokens.css';
|
import { designTokens } from '../../styles/tokens.css';
|
||||||
import { srOnly } from '../../styles/sr-only.css';
|
import { srOnly } from '../../styles/sr-only.css';
|
||||||
|
import { unownedLabel, unownedStyles } from '@utils/ownership';
|
||||||
import {
|
import {
|
||||||
LookupReleaseGroup,
|
LookupReleaseGroup,
|
||||||
BrowseReleases,
|
BrowseReleases,
|
||||||
@@ -29,12 +30,16 @@ import { Events } from '../../events';
|
|||||||
import '@awesome.me/webawesome/dist/components/icon/icon.js';
|
import '@awesome.me/webawesome/dist/components/icon/icon.js';
|
||||||
import '../library-status-indicator/library-status-indicator.js';
|
import '../library-status-indicator/library-status-indicator.js';
|
||||||
import { libraryStatusFor } from '@utils/library-status';
|
import { libraryStatusFor } from '@utils/library-status';
|
||||||
|
import { ICON_AUTOTAG } from '@utils/icon-language';
|
||||||
import type { LibraryStatus } from '../library-status-indicator/library-status-indicator.js';
|
import type { LibraryStatus } from '../library-status-indicator/library-status-indicator.js';
|
||||||
import '../catalog-scope-notice/catalog-scope-notice.js';
|
import '../catalog-scope-notice/catalog-scope-notice.js';
|
||||||
import type { CatalogScope } from '../catalog-scope-notice/catalog-scope-notice.js';
|
import type { CatalogScope } from '../catalog-scope-notice/catalog-scope-notice.js';
|
||||||
import '@awesome.me/webawesome/dist/components/button/button.js';
|
import '@awesome.me/webawesome/dist/components/button/button.js';
|
||||||
import '../download-picker/download-picker';
|
import '../download-picker/download-picker';
|
||||||
import { downloadStore } from '../../store/download-store';
|
import { downloadStore } from '../../store/download-store';
|
||||||
|
import { MatchForAlbum, ApplyAsync } from '@go/autotagservice/service.js';
|
||||||
|
import type * as autotagservice from '@go/autotagservice/models.js';
|
||||||
|
import { confirmAction } from '../confirm-dialog/confirm-dialog';
|
||||||
import { queueStore } from '../../store/queue-store';
|
import { queueStore } from '../../store/queue-store';
|
||||||
import type { QueueSource } from '../../store/queue-store';
|
import type { QueueSource } from '../../store/queue-store';
|
||||||
import { notificationStore } from '../../store/notification-store';
|
import { notificationStore } from '../../store/notification-store';
|
||||||
@@ -52,6 +57,12 @@ import { dictByName } from '@utils/binding';
|
|||||||
import type { TrackDetails } from '@components/track-details/track-details.js';
|
import type { TrackDetails } from '@components/track-details/track-details.js';
|
||||||
import { showTrackDetailsForPath } from '@utils/track-details-opener.js';
|
import { showTrackDetailsForPath } from '@utils/track-details-opener.js';
|
||||||
import '@components/playlist-picker/playlist-picker.js';
|
import '@components/playlist-picker/playlist-picker.js';
|
||||||
|
import {
|
||||||
|
ICON_CAN_REQUEST,
|
||||||
|
ICON_PLAYLIST,
|
||||||
|
ICON_QUEUE,
|
||||||
|
ICON_REQUESTED,
|
||||||
|
} from '@utils/icon-language';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The region the album header's own failures are rendered in.
|
* The region the album header's own failures are rendered in.
|
||||||
@@ -168,6 +179,19 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
|
|||||||
@property({ type: Number, attribute: 'local-album-id' })
|
@property({ type: Number, attribute: 'local-album-id' })
|
||||||
localAlbumId = 0;
|
localAlbumId = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A confident autotag match for this album, or null when there is
|
||||||
|
* none worth mentioning.
|
||||||
|
*
|
||||||
|
* The tier behind "confident" is `autotag.ConfidentTier`, decided
|
||||||
|
* in the backend so this page and strict auto-accept cannot
|
||||||
|
* disagree about what it means (#28, #90).
|
||||||
|
*/
|
||||||
|
@state() private autotagMatch: autotagservice.AlbumMatchView | null = null;
|
||||||
|
|
||||||
|
/** True while an apply started from this page is in flight. */
|
||||||
|
@state() private applyingTags = false;
|
||||||
|
|
||||||
/* ── Internal state ── */
|
/* ── Internal state ── */
|
||||||
|
|
||||||
@state() private releaseGroup: MBReleaseGroup | null = null;
|
@state() private releaseGroup: MBReleaseGroup | null = null;
|
||||||
@@ -191,6 +215,28 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
|
|||||||
@state() private versionEntries: VersionEntry[] = [];
|
@state() private versionEntries: VersionEntry[] = [];
|
||||||
/** Currently-selected dropdown entry (by VersionEntry.key). */
|
/** Currently-selected dropdown entry (by VersionEntry.key). */
|
||||||
@state() private selectedVersionKey: string = '';
|
@state() private selectedVersionKey: string = '';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The key `buildClusters` defaulted to, kept so the page can tell
|
||||||
|
* "this is what we picked for you" from "you went and chose this".
|
||||||
|
*
|
||||||
|
* Only the second needs saying out loud. With the selector demoted
|
||||||
|
* to a disclosure below the tracklist, a chosen version is the one
|
||||||
|
* case where the list on screen is not the one the header
|
||||||
|
* describes, and nothing else on the page would say so.
|
||||||
|
*/
|
||||||
|
@state() private defaultVersionKey: string = '';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the "Other versions" disclosure is open.
|
||||||
|
*
|
||||||
|
* Collapsed by default — choosing which pressing you are looking at
|
||||||
|
* is a metadata-repair task and does not belong above the
|
||||||
|
* tracklist. It is deliberately *not* closed when the selection
|
||||||
|
* changes: the user opened it to change something, and a panel that
|
||||||
|
* shuts on use cannot be used twice.
|
||||||
|
*/
|
||||||
|
@state() private versionsOpen = false;
|
||||||
@state() private coverArtURL = '';
|
@state() private coverArtURL = '';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -310,6 +356,7 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
|
|||||||
exploreLinkStyles,
|
exploreLinkStyles,
|
||||||
contextMenuStyles,
|
contextMenuStyles,
|
||||||
srOnly,
|
srOnly,
|
||||||
|
unownedStyles,
|
||||||
css`
|
css`
|
||||||
:host {
|
:host {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -515,7 +562,144 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Version selector ── */
|
/* ── The autotag suggestion ── */
|
||||||
|
.autotag-match {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border: 1px solid
|
||||||
|
var(--yj-border-subtle, rgba(255, 255, 255, 0.08));
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--yj-surface-1, rgba(255, 255, 255, 0.04));
|
||||||
|
}
|
||||||
|
|
||||||
|
.autotag-match > wa-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: var(--yj-icon-sm);
|
||||||
|
color: var(--yj-text-secondary, #b3b3b3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.autotag-match-text {
|
||||||
|
margin: 0;
|
||||||
|
flex: 1;
|
||||||
|
/* The suggestion sits in a flex row beside its buttons,
|
||||||
|
* and a grid/flex item's implicit minimum is its
|
||||||
|
* content — without this a long release title pushes
|
||||||
|
* the actions off the end at phone width. */
|
||||||
|
min-width: 0;
|
||||||
|
font-size: var(--yj-text-sm);
|
||||||
|
color: var(--yj-text-secondary, #b3b3b3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.autotag-match-text strong {
|
||||||
|
color: var(--yj-text-primary, #fff);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.autotag-match-note {
|
||||||
|
display: block;
|
||||||
|
margin-top: 2px;
|
||||||
|
font-size: var(--yj-text-xs);
|
||||||
|
color: var(--yj-text-tertiary, #888);
|
||||||
|
}
|
||||||
|
|
||||||
|
.autotag-match-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Other versions (a disclosure, below the tracklist) ── */
|
||||||
|
.versions {
|
||||||
|
margin-top: 24px;
|
||||||
|
border-top: 1px solid
|
||||||
|
var(--yj-border-subtle, rgba(255, 255, 255, 0.08));
|
||||||
|
padding-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The heading exists so the section is reachable by heading
|
||||||
|
* navigation; the button inside it is the control. Its own
|
||||||
|
* type scale is the section header's, reduced — this is a
|
||||||
|
* footnote to the page, not a peer of the tracklist. */
|
||||||
|
.versions-heading {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--yj-text-sm);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.versions-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 2px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--yj-text-secondary, #b3b3b3);
|
||||||
|
font: inherit;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.versions-toggle:hover {
|
||||||
|
color: var(--yj-text-primary, #fff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.versions-toggle:focus-visible {
|
||||||
|
outline: 2px solid var(--yj-accent-text, #ffd43b);
|
||||||
|
outline-offset: 2px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.versions-toggle wa-icon {
|
||||||
|
font-size: var(--yj-icon-xs, 11px);
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.versions-toggle[aria-expanded='false'] wa-icon {
|
||||||
|
transform: rotate(-90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.versions-intro {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
font-size: var(--yj-text-xs);
|
||||||
|
color: var(--yj-text-tertiary, #888);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A line above the tracklist, and only after a deliberate
|
||||||
|
* choice — see renderChosenVersion. */
|
||||||
|
.chosen-version {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
font-size: var(--yj-text-sm);
|
||||||
|
color: var(--yj-text-secondary, #b3b3b3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chosen-version strong {
|
||||||
|
color: var(--yj-text-primary, #fff);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chosen-version-reset {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
font: inherit;
|
||||||
|
color: var(--yj-accent-text, #ffd43b);
|
||||||
|
text-decoration: underline;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chosen-version-reset:focus-visible {
|
||||||
|
outline: 2px solid var(--yj-accent-text, #ffd43b);
|
||||||
|
outline-offset: 2px;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
.version-selector {
|
.version-selector {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -681,20 +865,14 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* A track the library does not have, on the pattern a
|
/* The dimming itself is unownedStyles, from
|
||||||
* streaming service uses for something it cannot play: the
|
* utils/ownership.ts, imported above. It was written here
|
||||||
* row stays, dimmed, so the album reads as the album rather
|
* first — this tracklist is where the treatment came from —
|
||||||
* than as the subset that happens to be here.
|
* and moved out when seven other surfaces had to draw the
|
||||||
*
|
* same thing, because two of them would otherwise have
|
||||||
* The dimming is a colour, so it cannot be the only signal
|
* ended up drawing it slightly differently. (No backticks or
|
||||||
* — the row also carries aria-disabled, which is what
|
* apostrophes-as-quotes here: this is inside a tagged
|
||||||
* reaches anyone not seeing it. Secondary rather than
|
* template literal.) */
|
||||||
* tertiary because the row's hover background is
|
|
||||||
* bgOverlay, which tertiary does not clear. */
|
|
||||||
.track-row.unowned .track-title {
|
|
||||||
color: var(--yj-text-secondary, #b3b3b3);
|
|
||||||
font-weight: 400;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* The request control is offered on every row that has
|
/* The request control is offered on every row that has
|
||||||
* something to request, and is not revealed on hover.
|
* something to request, and is not revealed on hover.
|
||||||
@@ -946,10 +1124,20 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
|
|||||||
this.releases = [];
|
this.releases = [];
|
||||||
this.versionEntries = [];
|
this.versionEntries = [];
|
||||||
this.selectedVersionKey = '';
|
this.selectedVersionKey = '';
|
||||||
|
this.defaultVersionKey = '';
|
||||||
|
this.versionsOpen = false;
|
||||||
this.showFullTracklist = null;
|
this.showFullTracklist = null;
|
||||||
this.localTracks = [];
|
this.localTracks = [];
|
||||||
this.filePaths = new Map();
|
this.filePaths = new Map();
|
||||||
this.askedFor = new Set();
|
this.askedFor = new Set();
|
||||||
|
this.autotagMatch = null;
|
||||||
|
|
||||||
|
// Not awaited: the banner is a bonus and the page must not
|
||||||
|
// wait on it. It is also the *most* useful on an untagged
|
||||||
|
// album, which is exactly the page that has least else to
|
||||||
|
// show, so it is asked for on both branches below rather than
|
||||||
|
// only the catalog one.
|
||||||
|
void this.loadAutotagMatch();
|
||||||
|
|
||||||
// Local-only album (no MBID) — populate entirely from library.
|
// Local-only album (no MBID) — populate entirely from library.
|
||||||
if (!mbid && this.localAlbumId) {
|
if (!mbid && this.localAlbumId) {
|
||||||
@@ -1142,6 +1330,31 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
|
|||||||
return this.completeness;
|
return this.completeness;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ask whether the autotagger already has a confident match here.
|
||||||
|
*
|
||||||
|
* It answers from what the background prefetch has already scored
|
||||||
|
* and makes no MusicBrainz request, so this is safe on page load —
|
||||||
|
* see `MatchForAlbum`. A folder nobody has reached yet answers
|
||||||
|
* `null`, which is the same as "nothing to say": the banner is a
|
||||||
|
* bonus, so a failure is a missing suggestion rather than an error
|
||||||
|
* the user can act on, and it stays in the console.
|
||||||
|
*/
|
||||||
|
private async loadAutotagMatch(): Promise<void> {
|
||||||
|
if (this.localAlbumId <= 0) {
|
||||||
|
this.autotagMatch = null;
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.autotagMatch = await MatchForAlbum(this.localAlbumId);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[explore-album] autotag match lookup failed', err);
|
||||||
|
this.autotagMatch = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch and set `localTracks` directly by local album id — the
|
* Fetch and set `localTracks` directly by local album id — the
|
||||||
* definite source of truth, used when nothing else has already
|
* definite source of truth, used when nothing else has already
|
||||||
@@ -1637,6 +1850,14 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
|
|||||||
|| standardEntry?.key
|
|| standardEntry?.key
|
||||||
|| this.versionEntries[0]?.key
|
|| this.versionEntries[0]?.key
|
||||||
|| '';
|
|| '';
|
||||||
|
|
||||||
|
// Recorded here rather than derived later: this is the one
|
||||||
|
// place that knows what "the version we picked" means, and
|
||||||
|
// recomputing the preference order at the render site would be
|
||||||
|
// a second copy of it. `handleTracklistScopeChange` rebuilds
|
||||||
|
// through here too, so the switch does not read as a choice of
|
||||||
|
// version.
|
||||||
|
this.defaultVersionKey = this.selectedVersionKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -2295,9 +2516,11 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
|
|||||||
entity-type="album"
|
entity-type="album"
|
||||||
@catalog-retry=${this.retryCatalog}
|
@catalog-retry=${this.retryCatalog}
|
||||||
></catalog-scope-notice>
|
></catalog-scope-notice>
|
||||||
${this.renderVersionSelector()}
|
${this.renderAutotagMatch()}
|
||||||
|
${this.renderChosenVersion()}
|
||||||
${this.renderTracklistScope()}
|
${this.renderTracklistScope()}
|
||||||
${this.renderTracklist()}
|
${this.renderTracklist()}
|
||||||
|
${this.renderVersionSelector()}
|
||||||
</div>
|
</div>
|
||||||
<track-details></track-details>
|
<track-details></track-details>
|
||||||
`;
|
`;
|
||||||
@@ -2437,7 +2660,7 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
|
|||||||
data-testid="album-queue"
|
data-testid="album-queue"
|
||||||
@click=${() => this.queueOwned()}
|
@click=${() => this.queueOwned()}
|
||||||
>
|
>
|
||||||
<wa-icon slot="start" name="list"></wa-icon>
|
<wa-icon slot="start" name=${ICON_QUEUE}></wa-icon>
|
||||||
Add to queue
|
Add to queue
|
||||||
</wa-button>
|
</wa-button>
|
||||||
${partial
|
${partial
|
||||||
@@ -2740,7 +2963,7 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
|
|||||||
of the same Free glyph carry the toggle instead. -->
|
of the same Free glyph carry the toggle instead. -->
|
||||||
<wa-icon
|
<wa-icon
|
||||||
slot="start"
|
slot="start"
|
||||||
name=${this.isRequested ? 'solid/bookmark' : 'regular/bookmark'}
|
name=${this.isRequested ? ICON_REQUESTED : ICON_CAN_REQUEST}
|
||||||
></wa-icon>
|
></wa-icon>
|
||||||
${this.isRequested ? 'Requested' : 'Request this'}
|
${this.isRequested ? 'Requested' : 'Request this'}
|
||||||
</wa-button>
|
</wa-button>
|
||||||
@@ -2948,26 +3171,230 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
|
|||||||
|
|
||||||
/* ── Version Selector (R025, R026, R027) ── */
|
/* ── Version Selector (R025, R026, R027) ── */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "MusicBrainz has a match for this album."
|
||||||
|
*
|
||||||
|
* The complaint this answers is that the user had to notice the
|
||||||
|
* metadata was missing, then go and hunt the album down on the
|
||||||
|
* Autotag page — so the point is to say it *here*, while they are
|
||||||
|
* looking at the thing, with something to do about it.
|
||||||
|
*
|
||||||
|
* Three things about it are load-bearing.
|
||||||
|
*
|
||||||
|
* **Applying is offered only when it would do the whole album.** A
|
||||||
|
* tagging group is a folder, so a multi-disc album is several, and
|
||||||
|
* one button that applied to the best-scoring one would leave the
|
||||||
|
* album holding a mix of old and new tags — the exact case the
|
||||||
|
* app's Blocking notification level exists for. `groupCount` is
|
||||||
|
* the test, and the answer there is review, not apply.
|
||||||
|
*
|
||||||
|
* **The confirm is not a formality.** This rewrites tags on disk
|
||||||
|
* and cannot be undone, so it goes through `confirmAction()` with
|
||||||
|
* an impact line that says so in those words.
|
||||||
|
*
|
||||||
|
* **The banner does not claim a percentage.** The backend has a
|
||||||
|
* score and deliberately does not put it in the sentence: 0.95
|
||||||
|
* reads as a probability and is not one. What the user needs is
|
||||||
|
* which release it is, which is what the release title and artist
|
||||||
|
* are for.
|
||||||
|
*/
|
||||||
|
private renderAutotagMatch() {
|
||||||
|
const match = this.autotagMatch;
|
||||||
|
|
||||||
|
if (!match) return nothing;
|
||||||
|
|
||||||
|
const wholeAlbum = match.groupCount === 1;
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<div class="autotag-match" role="status">
|
||||||
|
<wa-icon name=${ICON_AUTOTAG} aria-hidden="true"></wa-icon>
|
||||||
|
<p class="autotag-match-text">
|
||||||
|
MusicBrainz has a match for this album:
|
||||||
|
<strong>${match.title}</strong>
|
||||||
|
${match.artistCredit ? html` by ${match.artistCredit}` : nothing}.
|
||||||
|
${wholeAlbum
|
||||||
|
? nothing
|
||||||
|
: html`<span class="autotag-match-note"
|
||||||
|
>It is filed as ${match.groupCount} folders here, so
|
||||||
|
tagging it is a review rather than one
|
||||||
|
step.</span
|
||||||
|
>`}
|
||||||
|
</p>
|
||||||
|
<div class="autotag-match-actions">
|
||||||
|
${wholeAlbum
|
||||||
|
? html`<wa-button
|
||||||
|
size="small"
|
||||||
|
variant="brand"
|
||||||
|
?disabled=${this.applyingTags}
|
||||||
|
@click=${this.onApplyAutotagMatch}
|
||||||
|
>Apply tags</wa-button
|
||||||
|
>`
|
||||||
|
: nothing}
|
||||||
|
<wa-button
|
||||||
|
size="small"
|
||||||
|
appearance="outlined"
|
||||||
|
@click=${this.onReviewAutotagMatch}
|
||||||
|
>Review in Autotag</wa-button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hand the group over to the Autotag page and go there. */
|
||||||
|
private onReviewAutotagMatch = () => {
|
||||||
|
const match = this.autotagMatch;
|
||||||
|
|
||||||
|
if (!match) return;
|
||||||
|
|
||||||
|
this.dispatchEvent(
|
||||||
|
new CustomEvent('navigate', {
|
||||||
|
bubbles: true,
|
||||||
|
composed: true,
|
||||||
|
detail: { view: 'autotag', groupKey: match.groupKey },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply the match, after asking.
|
||||||
|
*
|
||||||
|
* `ApplyAsync` is the registered-job path, so the work is visible
|
||||||
|
* in the jobs indicator and cancellable there like every other
|
||||||
|
* long-running operation — this page does not grow a second
|
||||||
|
* progress surface for it. What it does own is the *acknowledgement*
|
||||||
|
* that the request was accepted, because the button is here.
|
||||||
|
*
|
||||||
|
* The page is not refreshed on completion either: rewriting tags
|
||||||
|
* emits `TrackMetadataChanged`, which `library-store` answers by
|
||||||
|
* discarding every cached collection, and this page reloads from
|
||||||
|
* that like everything else.
|
||||||
|
*/
|
||||||
|
private onApplyAutotagMatch = async () => {
|
||||||
|
const match = this.autotagMatch;
|
||||||
|
|
||||||
|
if (!match || this.applyingTags) return;
|
||||||
|
|
||||||
|
const ok = await confirmAction({
|
||||||
|
title: `Tag this album as “${match.title}”?`,
|
||||||
|
message:
|
||||||
|
`The ${match.trackCount} files of this album are rewritten to` +
|
||||||
|
` match the MusicBrainz release${
|
||||||
|
match.artistCredit ? ` by ${match.artistCredit}` : ''
|
||||||
|
}.`,
|
||||||
|
impact:
|
||||||
|
'This edits the tags in the files on disk and cannot be' +
|
||||||
|
' undone. Nothing is moved or deleted.',
|
||||||
|
confirmLabel: 'Apply tags',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!ok) return;
|
||||||
|
|
||||||
|
this.applyingTags = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ApplyAsync(match.groupKey, match.releaseMbid);
|
||||||
|
|
||||||
|
// The suggestion has been acted on, so it stops being a
|
||||||
|
// suggestion immediately rather than sitting there inviting
|
||||||
|
// a second click while the job runs.
|
||||||
|
this.autotagMatch = null;
|
||||||
|
|
||||||
|
notificationStore.transient({
|
||||||
|
text: 'Tagging this album — progress is in the jobs indicator.',
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[explore-album] autotag apply failed', err);
|
||||||
|
|
||||||
|
// Persistent rather than transient: the user asked for
|
||||||
|
// something that did not happen, and retrying is meaningful.
|
||||||
|
notificationStore.persistent({
|
||||||
|
text: describeError(
|
||||||
|
err,
|
||||||
|
'Those tags could not be applied.',
|
||||||
|
),
|
||||||
|
tone: 'error',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
this.applyingTags = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which pressing is on screen — said only when the user chose it.
|
||||||
|
*
|
||||||
|
* The selector is a disclosure below the tracklist now, so nothing
|
||||||
|
* above the list names the version it came from. That is right for
|
||||||
|
* the default, which is what the header already describes; it is
|
||||||
|
* wrong the moment someone picks a different one, because then the
|
||||||
|
* tracklist and the page disagree and the control that explains it
|
||||||
|
* is off the bottom of the screen.
|
||||||
|
*
|
||||||
|
* `defaultVersionKey` is the whole test. A quiet line that appears
|
||||||
|
* on every album would be the thing this issue removed, one size
|
||||||
|
* smaller.
|
||||||
|
*/
|
||||||
|
private renderChosenVersion() {
|
||||||
|
if (this.loadingReleases || this.errorReleases) return nothing;
|
||||||
|
if (!this.selectedVersionKey) return nothing;
|
||||||
|
if (this.selectedVersionKey === this.defaultVersionKey) return nothing;
|
||||||
|
|
||||||
|
const current = this.currentVersion();
|
||||||
|
|
||||||
|
if (!current) return nothing;
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<p class="chosen-version">
|
||||||
|
Showing <strong>${current.label}</strong> —
|
||||||
|
${current.sublabel}.
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="chosen-version-reset"
|
||||||
|
@click=${this.resetVersion}
|
||||||
|
>
|
||||||
|
Use the default version
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Back to what `buildClusters` picked, without opening the panel. */
|
||||||
|
private resetVersion = () => {
|
||||||
|
if (!this.defaultVersionKey) return;
|
||||||
|
|
||||||
|
this.selectedVersionKey = this.defaultVersionKey;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Other versions" — a disclosure, below the tracklist.
|
||||||
|
*
|
||||||
|
* Choosing which pressing you are looking at is an advanced,
|
||||||
|
* metadata-repair task, and it used to sit directly above the
|
||||||
|
* tracklist with a heading and a paragraph of prose explaining our
|
||||||
|
* clustering heuristic. It is not removed — matching the wrong
|
||||||
|
* release is a real problem and this is how it gets fixed — it is
|
||||||
|
* demoted (#17).
|
||||||
|
*
|
||||||
|
* Two things about the shape are load-bearing, and both are
|
||||||
|
* `config-section`'s rules rather than new ones. The header is a
|
||||||
|
* real `<button aria-expanded aria-controls>` inside the heading
|
||||||
|
* that names the section, so it is reachable by Tab and by heading
|
||||||
|
* navigation alike. And the body **renders unconditionally and is
|
||||||
|
* toggled with `hidden`**, because `aria-controls` has to name an
|
||||||
|
* element that is in the DOM.
|
||||||
|
*
|
||||||
|
* The loading and error states this used to own are gone rather
|
||||||
|
* than moved. Both were unguarded, so they took the primary slot on
|
||||||
|
* every album regardless of whether there was ever going to be a
|
||||||
|
* choice: the spinner said the same thing `renderTracklist` was
|
||||||
|
* already saying about the same fetch, and the error is the one
|
||||||
|
* `catalog-scope-notice` shows at the top of the page with a retry
|
||||||
|
* — every path that sets `errorReleases` also sets `catalogFailed`,
|
||||||
|
* which is the only route to `unavailable`. What the tracklist does
|
||||||
|
* with a failure is now the tracklist's own business.
|
||||||
|
*/
|
||||||
private renderVersionSelector() {
|
private renderVersionSelector() {
|
||||||
if (this.loadingReleases) {
|
if (this.loadingReleases || this.errorReleases) return nothing;
|
||||||
return html`
|
|
||||||
<section>
|
|
||||||
<h3 class="section-header">Versions</h3>
|
|
||||||
<div class="section-loading">Loading releases\u2026</div>
|
|
||||||
</section>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
if (this.errorReleases) {
|
|
||||||
return html`
|
|
||||||
<section>
|
|
||||||
<h3 class="section-header">Versions</h3>
|
|
||||||
<div class="section-error">
|
|
||||||
<wa-icon name="triangle-exclamation"></wa-icon>
|
|
||||||
${this.errorReleases}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// A dropdown is only a choice if the choices differ. Counting
|
// A dropdown is only a choice if the choices differ. Counting
|
||||||
// *entries* is the wrong test: a release group routinely has
|
// *entries* is the wrong test: a release group routinely has
|
||||||
@@ -2978,7 +3405,9 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
|
|||||||
//
|
//
|
||||||
// Distinct *tracklists* is the real question, and it is already
|
// Distinct *tracklists* is the real question, and it is already
|
||||||
// computed: clusters are keyed by tracklist fingerprint.
|
// computed: clusters are keyed by tracklist fingerprint.
|
||||||
if (this.distinctTracklistCount() <= 1) return nothing;
|
const choices = this.distinctTracklistCount();
|
||||||
|
|
||||||
|
if (choices <= 1) return nothing;
|
||||||
|
|
||||||
const aggregateEntries = this.versionEntries.filter(
|
const aggregateEntries = this.versionEntries.filter(
|
||||||
(e) => e.group === 'aggregate',
|
(e) => e.group === 'aggregate',
|
||||||
@@ -2988,35 +3417,59 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div class="version-selector">
|
<section class="versions">
|
||||||
<div class="version-selector-row">
|
<h3 class="versions-heading">
|
||||||
<label for="version-select">Version</label>
|
<button
|
||||||
<select
|
type="button"
|
||||||
id="version-select"
|
class="versions-toggle"
|
||||||
@change=${this.handleVersionChange}
|
aria-expanded=${this.versionsOpen ? 'true' : 'false'}
|
||||||
aria-label="Select release version"
|
aria-controls="versions-body"
|
||||||
|
@click=${this.toggleVersions}
|
||||||
>
|
>
|
||||||
${aggregateEntries.length > 0
|
<wa-icon name="chevron-down" aria-hidden="true"></wa-icon>
|
||||||
? html`
|
Other versions of this album (${choices})
|
||||||
<optgroup label="Aggregate">
|
</button>
|
||||||
${aggregateEntries.map((e) =>
|
</h3>
|
||||||
this.renderVersionOption(e),
|
<div id="versions-body" ?hidden=${!this.versionsOpen}>
|
||||||
)}
|
<p class="versions-intro">
|
||||||
</optgroup>
|
A release group can have several pressings with
|
||||||
`
|
different tracklists. Pick another if the one
|
||||||
: nothing}
|
above does not match your copy.
|
||||||
<optgroup label="Versions">
|
</p>
|
||||||
${clusterEntries.map((e) =>
|
<div class="version-selector">
|
||||||
this.renderVersionOption(e),
|
<div class="version-selector-row">
|
||||||
)}
|
<label for="version-select">Version</label>
|
||||||
</optgroup>
|
<select
|
||||||
</select>
|
id="version-select"
|
||||||
|
@change=${this.handleVersionChange}
|
||||||
|
>
|
||||||
|
${aggregateEntries.length > 0
|
||||||
|
? html`
|
||||||
|
<optgroup label="Aggregate">
|
||||||
|
${aggregateEntries.map((e) =>
|
||||||
|
this.renderVersionOption(e),
|
||||||
|
)}
|
||||||
|
</optgroup>
|
||||||
|
`
|
||||||
|
: nothing}
|
||||||
|
<optgroup label="Versions">
|
||||||
|
${clusterEntries.map((e) =>
|
||||||
|
this.renderVersionOption(e),
|
||||||
|
)}
|
||||||
|
</optgroup>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
${this.renderVersionMeta()}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
${this.renderVersionMeta()}
|
</section>
|
||||||
</div>
|
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private toggleVersions = () => {
|
||||||
|
this.versionsOpen = !this.versionsOpen;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One option in the version list.
|
* One option in the version list.
|
||||||
*
|
*
|
||||||
@@ -3195,8 +3648,21 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
if (this.errorReleases) {
|
if (this.errorReleases) {
|
||||||
// Error already shown in version selector section
|
// The failure belongs to the list that is missing because
|
||||||
return nothing;
|
// of it. This used to return `nothing` and lean on the
|
||||||
|
// version selector's own error block to have said it, which
|
||||||
|
// is precisely the coupling that made demoting the selector
|
||||||
|
// a rewrite rather than a move: a control in a collapsed
|
||||||
|
// disclosure cannot be the page's error surface.
|
||||||
|
return html`
|
||||||
|
<section>
|
||||||
|
<h3 class="sr-only">Tracklist</h3>
|
||||||
|
<div class="section-error">
|
||||||
|
<wa-icon name="triangle-exclamation"></wa-icon>
|
||||||
|
${this.errorReleases}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
`;
|
||||||
}
|
}
|
||||||
const current = this.currentVersion();
|
const current = this.currentVersion();
|
||||||
if (!current) {
|
if (!current) {
|
||||||
@@ -3267,7 +3733,7 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
|
|||||||
aria-disabled=${owned ? 'false' : 'true'}
|
aria-disabled=${owned ? 'false' : 'true'}
|
||||||
aria-label=${owned
|
aria-label=${owned
|
||||||
? `Play “${track.title}”`
|
? `Play “${track.title}”`
|
||||||
: `${track.title} — not in your library`}
|
: unownedLabel(track.title, 'track')}
|
||||||
@dblclick=${() => this.onTrackRowDblClick(track)}
|
@dblclick=${() => this.onTrackRowDblClick(track)}
|
||||||
@contextmenu=${(e: MouseEvent) => this.onTrackContextMenu(e, track)}
|
@contextmenu=${(e: MouseEvent) => this.onTrackContextMenu(e, track)}
|
||||||
@keydown=${(e: KeyboardEvent) => this.onTrackRowKeydown(e, track)}
|
@keydown=${(e: KeyboardEvent) => this.onTrackRowKeydown(e, track)}
|
||||||
@@ -3338,7 +3804,7 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
|
|||||||
@click=${() => this.onContextMenuAction('add-to-queue')}
|
@click=${() => this.onContextMenuAction('add-to-queue')}
|
||||||
@mouseenter=${() => this.ctxMenu.closePlaylistSubmenu()}
|
@mouseenter=${() => this.ctxMenu.closePlaylistSubmenu()}
|
||||||
>
|
>
|
||||||
<wa-icon slot="icon" name="plus"></wa-icon>
|
<wa-icon slot="icon" name=${ICON_QUEUE}></wa-icon>
|
||||||
Add to Queue
|
Add to Queue
|
||||||
</wa-dropdown-item>
|
</wa-dropdown-item>
|
||||||
<wa-dropdown-item
|
<wa-dropdown-item
|
||||||
@@ -3357,7 +3823,7 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
|
|||||||
this.openPlaylistSubmenu();
|
this.openPlaylistSubmenu();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<wa-icon slot="icon" name="plus"></wa-icon>
|
<wa-icon slot="icon" name=${ICON_PLAYLIST}></wa-icon>
|
||||||
Add to Playlist
|
Add to Playlist
|
||||||
<span class="submenu-arrow">▶</span>
|
<span class="submenu-arrow">▶</span>
|
||||||
</wa-dropdown-item>
|
</wa-dropdown-item>
|
||||||
|
|||||||
@@ -39,7 +39,17 @@ import { EventsOn } from '@runtime/runtime';
|
|||||||
import { Events } from '../../events';
|
import { Events } from '../../events';
|
||||||
import '@awesome.me/webawesome/dist/components/icon/icon.js';
|
import '@awesome.me/webawesome/dist/components/icon/icon.js';
|
||||||
import '../library-status-indicator/library-status-indicator.js';
|
import '../library-status-indicator/library-status-indicator.js';
|
||||||
import { libraryStatusFor, toggleRequest } from '@utils/library-status';
|
import {
|
||||||
|
albumBadgeFor,
|
||||||
|
libraryStatusFor,
|
||||||
|
toggleRequest,
|
||||||
|
} from '@utils/library-status';
|
||||||
|
import {
|
||||||
|
isOwned,
|
||||||
|
ownershipLabel,
|
||||||
|
unownedStyles,
|
||||||
|
} from '@utils/ownership';
|
||||||
|
import { completenessStore } from '@store/completeness-store';
|
||||||
import '../catalog-scope-notice/catalog-scope-notice.js';
|
import '../catalog-scope-notice/catalog-scope-notice.js';
|
||||||
import type { CatalogScope } from '../catalog-scope-notice/catalog-scope-notice.js';
|
import type { CatalogScope } from '../catalog-scope-notice/catalog-scope-notice.js';
|
||||||
import { queueStore } from '../../store/queue-store';
|
import { queueStore } from '../../store/queue-store';
|
||||||
@@ -59,6 +69,12 @@ import { dict, dictByName } from '@utils/binding';
|
|||||||
import type { TrackDetails } from '@components/track-details/track-details.js';
|
import type { TrackDetails } from '@components/track-details/track-details.js';
|
||||||
import { showTrackDetailsForPath } from '@utils/track-details-opener.js';
|
import { showTrackDetailsForPath } from '@utils/track-details-opener.js';
|
||||||
import '@components/playlist-picker/playlist-picker.js';
|
import '@components/playlist-picker/playlist-picker.js';
|
||||||
|
import {
|
||||||
|
ICON_CAN_REQUEST,
|
||||||
|
ICON_PLAYLIST,
|
||||||
|
ICON_QUEUE,
|
||||||
|
ICON_REQUESTED,
|
||||||
|
} from '@utils/icon-language';
|
||||||
|
|
||||||
/* ── Constants ── */
|
/* ── Constants ── */
|
||||||
|
|
||||||
@@ -172,7 +188,6 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
|
|||||||
@state() private discoRowSize = 5;
|
@state() private discoRowSize = 5;
|
||||||
private discoObserver?: ResizeObserver;
|
private discoObserver?: ResizeObserver;
|
||||||
@state() private similarExpanded = false;
|
@state() private similarExpanded = false;
|
||||||
private libraryMBIDs = new Set<string>();
|
|
||||||
|
|
||||||
/* ── Release prefetch ── */
|
/* ── Release prefetch ── */
|
||||||
|
|
||||||
@@ -251,6 +266,7 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
|
|||||||
designTokens,
|
designTokens,
|
||||||
exploreLinkStyles,
|
exploreLinkStyles,
|
||||||
contextMenuStyles,
|
contextMenuStyles,
|
||||||
|
unownedStyles,
|
||||||
css`
|
css`
|
||||||
:host {
|
:host {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -989,6 +1005,9 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
|
|||||||
/** Unsubscribe handle for the requests list. */
|
/** Unsubscribe handle for the requests list. */
|
||||||
private unsubRequests: (() => void) | null = null;
|
private unsubRequests: (() => void) | null = null;
|
||||||
|
|
||||||
|
/** Unsubscribes the "how much of this album is here" repaint. */
|
||||||
|
private unsubCompleteness: (() => void) | null = null;
|
||||||
|
|
||||||
override connectedCallback() {
|
override connectedCallback() {
|
||||||
super.connectedCallback();
|
super.connectedCallback();
|
||||||
if (this.artistMBID || this.localArtistId) {
|
if (this.artistMBID || this.localArtistId) {
|
||||||
@@ -1001,6 +1020,12 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
|
|||||||
this.unsubRequests = downloadStore.subscribe(() => this.requestUpdate());
|
this.unsubRequests = downloadStore.subscribe(() => this.requestUpdate());
|
||||||
void downloadStore.init().then(() => this.requestUpdate());
|
void downloadStore.init().then(() => this.requestUpdate());
|
||||||
|
|
||||||
|
// The count behind a partly-held album lands a frame after the
|
||||||
|
// cards do, since the store batches a screenful into one query.
|
||||||
|
this.unsubCompleteness = completenessStore.subscribe(() =>
|
||||||
|
this.requestUpdate(),
|
||||||
|
);
|
||||||
|
|
||||||
// A background discography fetch (top tracks / top releases for an
|
// A background discography fetch (top tracks / top releases for an
|
||||||
// artist that wasn't indexed yet) finished — re-fetch those two
|
// artist that wasn't indexed yet) finished — re-fetch those two
|
||||||
// sections, once per artist, so they fill in without the initial
|
// sections, once per artist, so they fill in without the initial
|
||||||
@@ -1039,6 +1064,8 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
|
|||||||
super.disconnectedCallback();
|
super.disconnectedCallback();
|
||||||
this.unsubRequests?.();
|
this.unsubRequests?.();
|
||||||
this.unsubRequests = null;
|
this.unsubRequests = null;
|
||||||
|
this.unsubCompleteness?.();
|
||||||
|
this.unsubCompleteness = null;
|
||||||
this.unsubDiscogReady?.();
|
this.unsubDiscogReady?.();
|
||||||
this.unsubSimilarReady?.();
|
this.unsubSimilarReady?.();
|
||||||
if (this.discogFallbackTimer) clearTimeout(this.discogFallbackTimer);
|
if (this.discogFallbackTimer) clearTimeout(this.discogFallbackTimer);
|
||||||
@@ -1567,10 +1594,6 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
|
|||||||
this.catalogPending = false;
|
this.catalogPending = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Populate libraryMBIDs from the inLibrary flag (already
|
|
||||||
// set by the backend via local_release_group_id cross-ref).
|
|
||||||
this.checkLibrary();
|
|
||||||
|
|
||||||
// Batch-resolve cover art for discography (lower priority — loaded after top sections).
|
// Batch-resolve cover art for discography (lower priority — loaded after top sections).
|
||||||
void this.batchResolveThumbnails(
|
void this.batchResolveThumbnails(
|
||||||
rgs?.map((r) => ({ mbid: r.mbid, albumName: r.title, artistName: r.artistCredit }))
|
rgs?.map((r) => ({ mbid: r.mbid, albumName: r.title, artistName: r.artistCredit }))
|
||||||
@@ -1897,23 +1920,6 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private checkLibrary() {
|
|
||||||
// Backend now populates `inLibrary` directly on each MBReleaseGroup
|
|
||||||
// via the local_release_group_id cross-reference column. Just read it.
|
|
||||||
let updated = false;
|
|
||||||
|
|
||||||
for (const rg of this.releaseGroups) {
|
|
||||||
if (rg.mbid && rg.inLibrary && !this.libraryMBIDs.has(rg.mbid)) {
|
|
||||||
this.libraryMBIDs.add(rg.mbid);
|
|
||||||
updated = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (updated) {
|
|
||||||
this.requestUpdate();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Playback ── */
|
/* ── Playback ── */
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -2009,11 +2015,14 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* File path for one top track, resolved by recording MBID — the
|
* File path for one top track, resolved by recording MBID — the
|
||||||
* same key `inLibrary`/`localId` were set from. Works whether or
|
* same key `localId` was set from. Works whether or not the
|
||||||
* not the containing release itself matched a local album.
|
* containing release itself matched a local album.
|
||||||
|
*
|
||||||
|
* Gated on the same answer the row is drawn from, or a row drawn
|
||||||
|
* dimmed and `aria-disabled` would still try to play and fail.
|
||||||
*/
|
*/
|
||||||
private async trackFilePath(track: LBTopRecording): Promise<string | null> {
|
private async trackFilePath(track: LBTopRecording): Promise<string | null> {
|
||||||
if (!(track.inLibrary || track.localId) || !track.recordingMbid) return null;
|
if (!isOwned(track) || !track.recordingMbid) return null;
|
||||||
|
|
||||||
const libraryID = libraryStore.getSelectedLibraryId() ?? 0;
|
const libraryID = libraryStore.getSelectedLibraryId() ?? 0;
|
||||||
const byMBID = await dictByName(
|
const byMBID = await dictByName(
|
||||||
@@ -2057,7 +2066,7 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
|
|||||||
}
|
}
|
||||||
|
|
||||||
private isTrackOwned(track: LBTopRecording): boolean {
|
private isTrackOwned(track: LBTopRecording): boolean {
|
||||||
return Boolean(track.inLibrary || track.localId);
|
return isOwned(track);
|
||||||
}
|
}
|
||||||
|
|
||||||
private onTrackRowDblClick(track: LBTopRecording): void {
|
private onTrackRowDblClick(track: LBTopRecording): void {
|
||||||
@@ -2100,7 +2109,7 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
|
|||||||
mbid: rg.releaseGroupMbid || '',
|
mbid: rg.releaseGroupMbid || '',
|
||||||
localId: rg.localId ?? 0,
|
localId: rg.localId ?? 0,
|
||||||
title: rg.title,
|
title: rg.title,
|
||||||
owned: Boolean(rg.inLibrary || rg.localId),
|
owned: isOwned(rg),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2120,10 +2129,12 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
|
|||||||
mbid: isLocal ? '' : rg.mbid || '',
|
mbid: isLocal ? '' : rg.mbid || '',
|
||||||
localId: Number.isFinite(localId) ? localId : 0,
|
localId: Number.isFinite(localId) ? localId : 0,
|
||||||
title: rg.title,
|
title: rg.title,
|
||||||
owned:
|
// The same answer the menu gates Play on, which is the
|
||||||
this.libraryMBIDs.has(rg.mbid) ||
|
// point: this used to be `inLibrary` too, so a card could
|
||||||
Boolean(rg.inLibrary) ||
|
// report itself owned, be offered no Play (that item is
|
||||||
localId > 0,
|
// gated on the local id) and be offered no request either
|
||||||
|
// (that one is gated on *not* owned).
|
||||||
|
owned: localId > 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2674,7 +2685,7 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
|
|||||||
@click=${() => this.onContextMenuAction('add-to-queue')}
|
@click=${() => this.onContextMenuAction('add-to-queue')}
|
||||||
@mouseenter=${() => this.ctxMenu.closePlaylistSubmenu()}
|
@mouseenter=${() => this.ctxMenu.closePlaylistSubmenu()}
|
||||||
>
|
>
|
||||||
<wa-icon slot="icon" name="plus"></wa-icon>
|
<wa-icon slot="icon" name=${ICON_QUEUE}></wa-icon>
|
||||||
Add to Queue
|
Add to Queue
|
||||||
</wa-dropdown-item>
|
</wa-dropdown-item>
|
||||||
<wa-dropdown-item
|
<wa-dropdown-item
|
||||||
@@ -2693,7 +2704,7 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
|
|||||||
void this.openPlaylistSubmenu(true);
|
void this.openPlaylistSubmenu(true);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<wa-icon slot="icon" name="plus"></wa-icon>
|
<wa-icon slot="icon" name=${ICON_PLAYLIST}></wa-icon>
|
||||||
Add to Playlist
|
Add to Playlist
|
||||||
<span class="submenu-arrow">▶</span>
|
<span class="submenu-arrow">▶</span>
|
||||||
</wa-dropdown-item>
|
</wa-dropdown-item>
|
||||||
@@ -2737,7 +2748,7 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
|
|||||||
Play
|
Play
|
||||||
</wa-dropdown-item>
|
</wa-dropdown-item>
|
||||||
<wa-dropdown-item @click=${() => void this.onReleaseAction('add-to-queue')}>
|
<wa-dropdown-item @click=${() => void this.onReleaseAction('add-to-queue')}>
|
||||||
<wa-icon slot="icon" name="plus"></wa-icon>
|
<wa-icon slot="icon" name=${ICON_QUEUE}></wa-icon>
|
||||||
Add to Queue
|
Add to Queue
|
||||||
</wa-dropdown-item>
|
</wa-dropdown-item>
|
||||||
<wa-dropdown-item @click=${() => void this.onReleaseAction('play-next')}>
|
<wa-dropdown-item @click=${() => void this.onReleaseAction('play-next')}>
|
||||||
@@ -2751,7 +2762,7 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
|
|||||||
<wa-dropdown-item @click=${() => void this.onReleaseRequestToggle()}>
|
<wa-dropdown-item @click=${() => void this.onReleaseRequestToggle()}>
|
||||||
<wa-icon
|
<wa-icon
|
||||||
slot="icon"
|
slot="icon"
|
||||||
name=${requested ? 'xmark' : 'bookmark'}
|
name=${requested ? ICON_REQUESTED : ICON_CAN_REQUEST}
|
||||||
></wa-icon>
|
></wa-icon>
|
||||||
${requested ? 'Cancel Request' : 'Request This'}
|
${requested ? 'Cancel Request' : 'Request This'}
|
||||||
</wa-dropdown-item>
|
</wa-dropdown-item>
|
||||||
@@ -2789,9 +2800,15 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
|
|||||||
appearance=${request ? 'filled' : 'outlined'}
|
appearance=${request ? 'filled' : 'outlined'}
|
||||||
@click=${() => void this.toggleFollow(request?.id)}
|
@click=${() => void this.toggleFollow(request?.id)}
|
||||||
>
|
>
|
||||||
|
<!-- This was bookmark-check, which is not in
|
||||||
|
names.txt and so has rendered the missing-icon
|
||||||
|
fallback — a circled question mark — on every
|
||||||
|
followed artist since it was written. A
|
||||||
|
backtick around that name would end this
|
||||||
|
template literal, which is why there is none. -->
|
||||||
<wa-icon
|
<wa-icon
|
||||||
slot="start"
|
slot="start"
|
||||||
name=${request ? 'bookmark-check' : 'bookmark'}
|
name=${request ? ICON_REQUESTED : ICON_CAN_REQUEST}
|
||||||
></wa-icon>
|
></wa-icon>
|
||||||
${request ? 'Following' : 'Follow for new releases'}
|
${request ? 'Following' : 'Follow for new releases'}
|
||||||
</wa-button>
|
</wa-button>
|
||||||
@@ -2932,15 +2949,20 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
|
|||||||
<div class="top-section-col top-section-col-tracks">
|
<div class="top-section-col top-section-col-tracks">
|
||||||
<h3 class="section-header">Top Tracks</h3>
|
<h3 class="section-header">Top Tracks</h3>
|
||||||
<div class="track-list">
|
<div class="track-list">
|
||||||
${tracks.map(
|
${tracks.map((t, i) => {
|
||||||
(t, i) => html`
|
const owned = this.isTrackOwned(t);
|
||||||
|
|
||||||
|
return html`
|
||||||
<div
|
<div
|
||||||
class=${classMap({ 'track-item': true, owned: this.isTrackOwned(t) })}
|
class=${classMap({
|
||||||
|
'track-item': true,
|
||||||
|
owned,
|
||||||
|
unowned: !owned,
|
||||||
|
})}
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
role="button"
|
role="button"
|
||||||
aria-label=${this.isTrackOwned(t)
|
aria-disabled=${owned ? 'false' : 'true'}
|
||||||
? `Play “${t.trackName}”`
|
aria-label=${ownershipLabel(owned, 'Play', t.trackName, 'track')}
|
||||||
: `${t.trackName} — not in your library`}
|
|
||||||
@dblclick=${() => this.onTrackRowDblClick(t)}
|
@dblclick=${() => this.onTrackRowDblClick(t)}
|
||||||
@contextmenu=${(e: MouseEvent) => this.onTrackContextMenu(e, t)}
|
@contextmenu=${(e: MouseEvent) => this.onTrackContextMenu(e, t)}
|
||||||
@keydown=${(e: KeyboardEvent) => this.onTrackRowKeydown(e, t)}
|
@keydown=${(e: KeyboardEvent) => this.onTrackRowKeydown(e, t)}
|
||||||
@@ -2965,16 +2987,18 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
|
|||||||
<span class="track-listens">
|
<span class="track-listens">
|
||||||
${formatListenCount(t.totalListenCount)} plays
|
${formatListenCount(t.totalListenCount)} plays
|
||||||
</span>
|
</span>
|
||||||
<library-status-indicator
|
${owned
|
||||||
status=${libraryStatusFor(Boolean(t.inLibrary || t.localId), t.recordingMbid)}
|
? nothing
|
||||||
entity-type="track"
|
: html`<library-status-indicator
|
||||||
label=${t.trackName}
|
status=${libraryStatusFor(false, t.recordingMbid)}
|
||||||
request-mbid=${t.recordingMbid}
|
entity-type="track"
|
||||||
request-artist=${t.artistName ?? ''}
|
label=${t.trackName}
|
||||||
></library-status-indicator>
|
request-mbid=${t.recordingMbid}
|
||||||
|
request-artist=${t.artistName ?? ''}
|
||||||
|
></library-status-indicator>`}
|
||||||
</div>
|
</div>
|
||||||
`,
|
`;
|
||||||
)}
|
})}
|
||||||
</div>
|
</div>
|
||||||
${canExpandTracks
|
${canExpandTracks
|
||||||
? html`
|
? html`
|
||||||
@@ -3045,10 +3069,16 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
|
|||||||
private renderTopReleaseCard(rg: LBTopReleaseGroup) {
|
private renderTopReleaseCard(rg: LBTopReleaseGroup) {
|
||||||
const artURL = this.thumbnailURLs.get(rg.releaseGroupMbid) || '';
|
const artURL = this.thumbnailURLs.get(rg.releaseGroupMbid) || '';
|
||||||
const target = this.topReleaseTarget(rg);
|
const target = this.topReleaseTarget(rg);
|
||||||
|
const owned = target.owned;
|
||||||
|
const badge = albumBadgeFor(
|
||||||
|
{ localId: target.localId },
|
||||||
|
rg.releaseGroupMbid,
|
||||||
|
);
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div
|
<div
|
||||||
class="top-release-card"
|
class=${classMap({ 'top-release-card': true, unowned: !owned })}
|
||||||
|
aria-label=${ownershipLabel(owned, 'Album', rg.title, 'album')}
|
||||||
@click=${() => this.navigateToTopRelease(rg)}
|
@click=${() => this.navigateToTopRelease(rg)}
|
||||||
role="button"
|
role="button"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
@@ -3080,14 +3110,18 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
|
|||||||
<div class="top-release-meta-text">
|
<div class="top-release-meta-text">
|
||||||
${rg.date ? html`<span>${extractYear(rg.date)}</span>` : nothing}
|
${rg.date ? html`<span>${extractYear(rg.date)}</span>` : nothing}
|
||||||
</div>
|
</div>
|
||||||
<library-status-indicator
|
${badge.status === 'in-library'
|
||||||
status=${libraryStatusFor(Boolean(rg.inLibrary || rg.localId), rg.releaseGroupMbid)}
|
? nothing
|
||||||
entity-type="album"
|
: html`<library-status-indicator
|
||||||
label=${rg.title}
|
status=${badge.status}
|
||||||
request-mbid=${rg.releaseGroupMbid}
|
owned=${badge.owned}
|
||||||
request-artist=${this.artist?.name ?? ''}
|
expected=${badge.expected}
|
||||||
size="18"
|
entity-type="album"
|
||||||
></library-status-indicator>
|
label=${rg.title}
|
||||||
|
request-mbid=${rg.releaseGroupMbid}
|
||||||
|
request-artist=${this.artist?.name ?? ''}
|
||||||
|
size="18"
|
||||||
|
></library-status-indicator>`}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -3177,14 +3211,15 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
|
|||||||
private renderAlbumCard(rg: MBReleaseGroup) {
|
private renderAlbumCard(rg: MBReleaseGroup) {
|
||||||
const artURL = this.thumbnailURLs.get(rg.mbid) || '';
|
const artURL = this.thumbnailURLs.get(rg.mbid) || '';
|
||||||
const year = extractYear(rg.firstReleaseDate);
|
const year = extractYear(rg.firstReleaseDate);
|
||||||
const inLibrary = this.libraryMBIDs.has(rg.mbid) || Boolean(rg.inLibrary);
|
|
||||||
const status = libraryStatusFor(inLibrary, rg.mbid);
|
|
||||||
|
|
||||||
const target = this.albumTarget(rg);
|
const target = this.albumTarget(rg);
|
||||||
|
const owned = target.owned;
|
||||||
|
const badge = albumBadgeFor({ localId: target.localId }, target.mbid);
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div
|
<div
|
||||||
class="album-card"
|
class=${classMap({ 'album-card': true, unowned: !owned })}
|
||||||
|
aria-label=${ownershipLabel(owned, 'Album', rg.title, 'album')}
|
||||||
@click=${() => this.navigateToAlbum(rg)}
|
@click=${() => this.navigateToAlbum(rg)}
|
||||||
role="button"
|
role="button"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
@@ -3213,13 +3248,17 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
|
|||||||
<div class="album-meta-text">
|
<div class="album-meta-text">
|
||||||
${year ? html`<span>${year}</span>` : nothing}
|
${year ? html`<span>${year}</span>` : nothing}
|
||||||
</div>
|
</div>
|
||||||
<library-status-indicator
|
${badge.status === 'in-library'
|
||||||
status=${status}
|
? nothing
|
||||||
entity-type="album"
|
: html`<library-status-indicator
|
||||||
label=${rg.title}
|
status=${badge.status}
|
||||||
request-mbid=${rg.mbid}
|
owned=${badge.owned}
|
||||||
request-artist=${this.artist?.name ?? ''}
|
expected=${badge.expected}
|
||||||
></library-status-indicator>
|
entity-type="album"
|
||||||
|
label=${rg.title}
|
||||||
|
request-mbid=${rg.mbid}
|
||||||
|
request-artist=${this.artist?.name ?? ''}
|
||||||
|
></library-status-indicator>`}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import { avatarBackground } from '@utils/avatar-color';
|
import { avatarBackground } from '@utils/avatar-color';
|
||||||
import { libraryStatusFor } from '@utils/library-status';
|
import { albumBadgeFor, libraryStatusFor } from '@utils/library-status';
|
||||||
|
import {
|
||||||
|
isOwned,
|
||||||
|
ownershipLabel,
|
||||||
|
unownedStyles,
|
||||||
|
} from '@utils/ownership';
|
||||||
|
import { completenessStore } from '@store/completeness-store';
|
||||||
import { downloadStore } from '@store/download-store';
|
import { downloadStore } from '@store/download-store';
|
||||||
import { LitElement, html, css, nothing } from 'lit';
|
import { LitElement, html, css, nothing } from 'lit';
|
||||||
import { customElement, state, query as litQuery } from 'lit/decorators.js';
|
import { customElement, state, query as litQuery } from 'lit/decorators.js';
|
||||||
@@ -36,6 +42,7 @@ import '@awesome.me/webawesome/dist/components/popup/popup.js';
|
|||||||
import type WaPopup from '@awesome.me/webawesome/dist/components/popup/popup.js';
|
import type WaPopup from '@awesome.me/webawesome/dist/components/popup/popup.js';
|
||||||
import '@awesome.me/webawesome/dist/components/dropdown-item/dropdown-item.js';
|
import '@awesome.me/webawesome/dist/components/dropdown-item/dropdown-item.js';
|
||||||
import { dict, dictByName } from '@utils/binding';
|
import { dict, dictByName } from '@utils/binding';
|
||||||
|
import { ICON_QUEUE } from '@utils/icon-language';
|
||||||
|
|
||||||
/** The region explore's own action failures (play/queue) are rendered in. */
|
/** The region explore's own action failures (play/queue) are rendered in. */
|
||||||
export const ExploreRegion = 'explore';
|
export const ExploreRegion = 'explore';
|
||||||
@@ -170,7 +177,6 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) implements Conte
|
|||||||
private searchDebounceTimer?: ReturnType<typeof setTimeout>;
|
private searchDebounceTimer?: ReturnType<typeof setTimeout>;
|
||||||
private thumbnailCache = new LRUMap<string, string>(THUMBNAIL_CACHE_LIMIT);
|
private thumbnailCache = new LRUMap<string, string>(THUMBNAIL_CACHE_LIMIT);
|
||||||
private artistImageCache = new LRUMap<string, string>(ARTIST_IMAGE_CACHE_LIMIT);
|
private artistImageCache = new LRUMap<string, string>(ARTIST_IMAGE_CACHE_LIMIT);
|
||||||
private libraryMBIDs = new Set<string>();
|
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
@@ -225,6 +231,7 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) implements Conte
|
|||||||
srOnly,
|
srOnly,
|
||||||
exploreLinkStyles,
|
exploreLinkStyles,
|
||||||
contextMenuStyles,
|
contextMenuStyles,
|
||||||
|
unownedStyles,
|
||||||
css`
|
css`
|
||||||
:host {
|
:host {
|
||||||
display: block;
|
display: block;
|
||||||
@@ -811,6 +818,13 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) implements Conte
|
|||||||
// Explore should not pay for it.
|
// Explore should not pay for it.
|
||||||
this.whileActive(downloadStore.subscribe(() => this.requestUpdate()));
|
this.whileActive(downloadStore.subscribe(() => this.requestUpdate()));
|
||||||
void downloadStore.init().then(() => this.requestUpdate());
|
void downloadStore.init().then(() => this.requestUpdate());
|
||||||
|
|
||||||
|
// How much of an owned album is here arrives a frame after the
|
||||||
|
// cards do — the store coalesces a screenful into one query —
|
||||||
|
// so a card that turns out to be 9 of 12 repaints when the
|
||||||
|
// answer lands rather than showing a plain tick until something
|
||||||
|
// else happens to re-render the grid.
|
||||||
|
this.whileActive(completenessStore.subscribe(() => this.requestUpdate()));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A debounced search that lands after the user has left the page is
|
/** A debounced search that lands after the user has left the page is
|
||||||
@@ -1082,7 +1096,6 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) implements Conte
|
|||||||
this.results?.artists ?? [],
|
this.results?.artists ?? [],
|
||||||
this.results?.releaseGroups ?? [],
|
this.results?.releaseGroups ?? [],
|
||||||
);
|
);
|
||||||
this.checkLibrary();
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (version !== this.searchVersion) return;
|
if (version !== this.searchVersion) return;
|
||||||
|
|
||||||
@@ -1228,8 +1241,11 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) implements Conte
|
|||||||
void this.playAlbum(rg, false);
|
void this.playAlbum(rg, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
private onRecordingRowDblClick(r: { mbid: string; inLibrary: boolean; localId?: number }): void {
|
// The same answer the row is drawn from. It used to accept
|
||||||
if (!r.inLibrary && !r.localId) return;
|
// `inLibrary` as well, so a row drawn dimmed and `aria-disabled`
|
||||||
|
// would still try to play and fail with a notification.
|
||||||
|
private onRecordingRowDblClick(r: { mbid: string; localId?: number }): void {
|
||||||
|
if (!isOwned(r)) return;
|
||||||
|
|
||||||
void this.playRecording(r.mbid);
|
void this.playRecording(r.mbid);
|
||||||
}
|
}
|
||||||
@@ -1342,7 +1358,7 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) implements Conte
|
|||||||
Play
|
Play
|
||||||
</wa-dropdown-item>
|
</wa-dropdown-item>
|
||||||
<wa-dropdown-item @click=${() => this.onContextMenuAction('add-to-queue')}>
|
<wa-dropdown-item @click=${() => this.onContextMenuAction('add-to-queue')}>
|
||||||
<wa-icon slot="icon" name="plus"></wa-icon>
|
<wa-icon slot="icon" name=${ICON_QUEUE}></wa-icon>
|
||||||
Add to Queue
|
Add to Queue
|
||||||
</wa-dropdown-item>
|
</wa-dropdown-item>
|
||||||
<wa-dropdown-item @click=${() => this.onContextMenuAction('play-next')}>
|
<wa-dropdown-item @click=${() => this.onContextMenuAction('play-next')}>
|
||||||
@@ -1664,42 +1680,6 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) implements Conte
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Check which result MBIDs exist in the local library.
|
|
||||||
*/
|
|
||||||
private checkLibrary() {
|
|
||||||
if (!this.results) return;
|
|
||||||
|
|
||||||
// Backend now populates `inLibrary` directly on each MB result
|
|
||||||
// via the local_*_id cross-reference columns. Just read those.
|
|
||||||
let updated = false;
|
|
||||||
|
|
||||||
for (const a of this.results.artists ?? []) {
|
|
||||||
if (a.mbid && a.inLibrary && !this.libraryMBIDs.has(a.mbid)) {
|
|
||||||
this.libraryMBIDs.add(a.mbid);
|
|
||||||
updated = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const rg of this.results.releaseGroups ?? []) {
|
|
||||||
if (rg.mbid && rg.inLibrary && !this.libraryMBIDs.has(rg.mbid)) {
|
|
||||||
this.libraryMBIDs.add(rg.mbid);
|
|
||||||
updated = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const r of this.results.recordings ?? []) {
|
|
||||||
if (r.mbid && r.inLibrary && !this.libraryMBIDs.has(r.mbid)) {
|
|
||||||
this.libraryMBIDs.add(r.mbid);
|
|
||||||
updated = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (updated) {
|
|
||||||
this.requestUpdate();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Navigation ── */
|
/* ── Navigation ── */
|
||||||
|
|
||||||
private navigateToArtist(artist: MBArtist) {
|
private navigateToArtist(artist: MBArtist) {
|
||||||
@@ -2106,12 +2086,16 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) implements Conte
|
|||||||
: nothing}
|
: nothing}
|
||||||
<div class="horizontal-row">
|
<div class="horizontal-row">
|
||||||
${artists.map((a) => {
|
${artists.map((a) => {
|
||||||
|
const owned = isOwned(a);
|
||||||
|
const name = a.englishName || a.name;
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div
|
<div
|
||||||
class="artist-card"
|
class=${classMap({ 'artist-card': true, unowned: !owned })}
|
||||||
@click=${() => this.navigateToArtist(a)}
|
@click=${() => this.navigateToArtist(a)}
|
||||||
role="button"
|
role="button"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
|
aria-label=${ownershipLabel(owned, 'Artist', name, 'artist')}
|
||||||
@keydown=${(e: KeyboardEvent) => {
|
@keydown=${(e: KeyboardEvent) => {
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -2170,11 +2154,17 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) implements Conte
|
|||||||
const artURL = this.thumbnailCache.get(rg.mbid) || '';
|
const artURL = this.thumbnailCache.get(rg.mbid) || '';
|
||||||
const year = extractYear(rg.firstReleaseDate);
|
const year = extractYear(rg.firstReleaseDate);
|
||||||
|
|
||||||
const owned = Boolean(rg.localId);
|
const owned = isOwned(rg);
|
||||||
|
const badge = albumBadgeFor(rg, rg.mbid);
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div
|
<div
|
||||||
class=${classMap({ 'album-card': true, owned })}
|
class=${classMap({
|
||||||
|
'album-card': true,
|
||||||
|
owned,
|
||||||
|
unowned: !owned,
|
||||||
|
})}
|
||||||
|
aria-label=${ownershipLabel(owned, 'Album', rg.title, 'album')}
|
||||||
@click=${() => this.navigateToAlbum(rg)}
|
@click=${() => this.navigateToAlbum(rg)}
|
||||||
@dblclick=${() => this.onAlbumCardDblClick(rg)}
|
@dblclick=${() => this.onAlbumCardDblClick(rg)}
|
||||||
@contextmenu=${(e: MouseEvent) =>
|
@contextmenu=${(e: MouseEvent) =>
|
||||||
@@ -2227,13 +2217,17 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) implements Conte
|
|||||||
: nothing}
|
: nothing}
|
||||||
${year ? html`<span>${year}</span>` : nothing}
|
${year ? html`<span>${year}</span>` : nothing}
|
||||||
</div>
|
</div>
|
||||||
<library-status-indicator
|
${badge.status === 'in-library'
|
||||||
status=${libraryStatusFor(this.libraryMBIDs.has(rg.mbid) || Boolean(rg.inLibrary), rg.mbid)}
|
? nothing
|
||||||
entity-type="album"
|
: html`<library-status-indicator
|
||||||
label=${rg.title}
|
status=${badge.status}
|
||||||
request-mbid=${rg.mbid}
|
owned=${badge.owned}
|
||||||
request-artist=${rg.artistCredit ?? ''}
|
expected=${badge.expected}
|
||||||
></library-status-indicator>
|
entity-type="album"
|
||||||
|
label=${rg.title}
|
||||||
|
request-mbid=${rg.mbid}
|
||||||
|
request-artist=${rg.artistCredit ?? ''}
|
||||||
|
></library-status-indicator>`}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -2248,12 +2242,20 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) implements Conte
|
|||||||
<section>
|
<section>
|
||||||
<h3 class="section-header">Tracks</h3>
|
<h3 class="section-header">Tracks</h3>
|
||||||
<div class="track-list">
|
<div class="track-list">
|
||||||
${recordings.map(
|
${recordings.map((r) => {
|
||||||
(r) => html`
|
const owned = isOwned(r);
|
||||||
|
|
||||||
|
return html`
|
||||||
<div
|
<div
|
||||||
class=${classMap({ 'track-item': true, owned: Boolean(r.inLibrary || r.localId) })}
|
class=${classMap({
|
||||||
|
'track-item': true,
|
||||||
|
owned,
|
||||||
|
unowned: !owned,
|
||||||
|
})}
|
||||||
role="button"
|
role="button"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
|
aria-disabled=${owned ? 'false' : 'true'}
|
||||||
|
aria-label=${ownershipLabel(owned, 'Play', r.title, 'track')}
|
||||||
@dblclick=${() => this.onRecordingRowDblClick(r)}
|
@dblclick=${() => this.onRecordingRowDblClick(r)}
|
||||||
@contextmenu=${(e: MouseEvent) =>
|
@contextmenu=${(e: MouseEvent) =>
|
||||||
this.onExploreContextMenu(e, {
|
this.onExploreContextMenu(e, {
|
||||||
@@ -2290,16 +2292,18 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) implements Conte
|
|||||||
? html`<span class="track-duration">${formatDuration(r.length)}</span>`
|
? html`<span class="track-duration">${formatDuration(r.length)}</span>`
|
||||||
: nothing}
|
: nothing}
|
||||||
</div>
|
</div>
|
||||||
<library-status-indicator
|
${owned
|
||||||
status=${libraryStatusFor(this.libraryMBIDs.has(r.mbid) || Boolean(r.inLibrary), r.mbid)}
|
? nothing
|
||||||
entity-type="track"
|
: html`<library-status-indicator
|
||||||
label=${r.title}
|
status=${libraryStatusFor(false, r.mbid)}
|
||||||
request-mbid=${r.mbid}
|
entity-type="track"
|
||||||
request-artist=${r.artistCredit ?? ''}
|
label=${r.title}
|
||||||
></library-status-indicator>
|
request-mbid=${r.mbid}
|
||||||
|
request-artist=${r.artistCredit ?? ''}
|
||||||
|
></library-status-indicator>`}
|
||||||
</div>
|
</div>
|
||||||
`,
|
`;
|
||||||
)}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -35,6 +35,10 @@ import type WaPopup from '@awesome.me/webawesome/dist/components/popup/popup.js'
|
|||||||
import '@awesome.me/webawesome/dist/components/dropdown-item/dropdown-item.js';
|
import '@awesome.me/webawesome/dist/components/dropdown-item/dropdown-item.js';
|
||||||
import '@components/playlist-picker/playlist-picker.js';
|
import '@components/playlist-picker/playlist-picker.js';
|
||||||
import { dictByName } from '@utils/binding';
|
import { dictByName } from '@utils/binding';
|
||||||
|
import {
|
||||||
|
ICON_PLAYLIST,
|
||||||
|
ICON_QUEUE,
|
||||||
|
} from '@utils/icon-language';
|
||||||
|
|
||||||
/** Pixels to change card width per scroll tick. */
|
/** Pixels to change card width per scroll tick. */
|
||||||
const ZOOM_STEP = 16;
|
const ZOOM_STEP = 16;
|
||||||
@@ -1211,7 +1215,7 @@ export class GenresView
|
|||||||
>
|
>
|
||||||
<wa-icon
|
<wa-icon
|
||||||
slot="icon"
|
slot="icon"
|
||||||
name="plus"
|
name=${ICON_QUEUE}
|
||||||
></wa-icon>
|
></wa-icon>
|
||||||
Add to Queue
|
Add to Queue
|
||||||
</wa-dropdown-item>
|
</wa-dropdown-item>
|
||||||
@@ -1257,7 +1261,7 @@ export class GenresView
|
|||||||
>
|
>
|
||||||
<wa-icon
|
<wa-icon
|
||||||
slot="icon"
|
slot="icon"
|
||||||
name="plus"
|
name=${ICON_PLAYLIST}
|
||||||
></wa-icon>
|
></wa-icon>
|
||||||
Add to Playlist
|
Add to Playlist
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -160,29 +160,52 @@ export class HomeView extends ViewLifecycleMixin(LitElement) {
|
|||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The hover play button is a *hover* affordance, so it is
|
||||||
|
* gated on the device having hover rather than on width. A
|
||||||
|
* touch long-press synthesises a hover state in the WebView,
|
||||||
|
* so on a phone it flashed into view during the 500ms hold
|
||||||
|
* that utils/long-press.ts is measuring for a context menu —
|
||||||
|
* a control appearing because you were reaching for a
|
||||||
|
* different one. A phone user taps the album and plays from
|
||||||
|
* the detail view, so there is nothing to replace it with.
|
||||||
|
*
|
||||||
|
* display:none outside the query rather than opacity:0 on
|
||||||
|
* its own: an opacity-0 button still takes taps and is
|
||||||
|
* still in the accessibility tree, so the invisible control
|
||||||
|
* would keep the hit area it was never meant to have on
|
||||||
|
* touch. Everything else stays inside, so the desktop
|
||||||
|
* animation is unchanged.
|
||||||
|
*/
|
||||||
.play {
|
.play {
|
||||||
position: absolute;
|
display: none;
|
||||||
right: 8px;
|
|
||||||
bottom: 8px;
|
|
||||||
width: 38px;
|
|
||||||
height: 38px;
|
|
||||||
border: none;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--yj-accent, #ffd43b);
|
|
||||||
color: var(--yj-accent-fg, #000);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
cursor: pointer;
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(6px);
|
|
||||||
transition: opacity 0.12s ease, transform 0.12s ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.card:hover .play,
|
@media (hover: hover) and (pointer: fine) {
|
||||||
.card:focus-within .play {
|
.play {
|
||||||
opacity: 1;
|
position: absolute;
|
||||||
transform: translateY(0);
|
right: 8px;
|
||||||
|
bottom: 8px;
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--yj-accent, #ffd43b);
|
||||||
|
color: var(--yj-accent-fg, #000);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(6px);
|
||||||
|
transition: opacity 0.12s ease, transform 0.12s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:hover .play,
|
||||||
|
.card:focus-within .play {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.name {
|
.name {
|
||||||
|
|||||||
@@ -4,6 +4,11 @@ import '@awesome.me/webawesome/dist/components/icon/icon.js';
|
|||||||
import { toggleRequest } from '@utils/library-status';
|
import { toggleRequest } from '@utils/library-status';
|
||||||
import { notificationStore } from '@store/notification-store';
|
import { notificationStore } from '@store/notification-store';
|
||||||
import { describeError } from '@utils/describe-error';
|
import { describeError } from '@utils/describe-error';
|
||||||
|
import {
|
||||||
|
ICON_CAN_REQUEST,
|
||||||
|
ICON_IN_LIBRARY,
|
||||||
|
ICON_REQUESTED,
|
||||||
|
} from '@utils/icon-language';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Library status for an entity (artist, album, or track).
|
* Library status for an entity (artist, album, or track).
|
||||||
@@ -248,18 +253,25 @@ export class LibraryStatusIndicator extends LitElement {
|
|||||||
* hourglass says "wait, this is under way", which overstates what a
|
* hourglass says "wait, this is under way", which overstates what a
|
||||||
* request is: nothing may be downloading, nothing may ever be found,
|
* request is: nothing may be downloading, nothing may ever be found,
|
||||||
* and the user can leave one sitting on the list indefinitely. A
|
* and the user can leave one sitting on the list indefinitely. A
|
||||||
* bookmark says the honest thing — it is on your list — and reads as
|
* bookmark says the honest thing — it is on your list.
|
||||||
* the opposite of the plus that put it there, which is what a
|
*
|
||||||
* toggle's two states have to do.
|
* The *other* state is the outline of that same bookmark, not a
|
||||||
|
* plus. Two states of one toggle have to read as each other's
|
||||||
|
* opposite, and a plus and a bookmark do not — this badge showed a
|
||||||
|
* plus on the same page as a "Request this" button already using
|
||||||
|
* the outline/solid pair, forty pixels away. That is the fault
|
||||||
|
* `utils/library-status.ts` was written for, one layer down: it
|
||||||
|
* made the two agree on what wanting *means* and left them
|
||||||
|
* disagreeing on what it looks like.
|
||||||
*/
|
*/
|
||||||
private iconName(): string {
|
private iconName(): string {
|
||||||
switch (this.status) {
|
switch (this.status) {
|
||||||
case 'in-library':
|
case 'in-library':
|
||||||
return 'check';
|
return ICON_IN_LIBRARY;
|
||||||
case 'queued':
|
case 'queued':
|
||||||
return 'bookmark';
|
return ICON_REQUESTED;
|
||||||
default:
|
default:
|
||||||
return 'plus';
|
return ICON_CAN_REQUEST;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -285,9 +297,23 @@ export class LibraryStatusIndicator extends LitElement {
|
|||||||
// row to one and nothing to the other, and "Add … to library"
|
// row to one and nothing to the other, and "Add … to library"
|
||||||
// was the old button's promise written into the copy.
|
// was the old button's promise written into the copy.
|
||||||
if (this.actionable) {
|
if (this.actionable) {
|
||||||
return this.status === 'queued'
|
if (this.status === 'queued') {
|
||||||
? `Cancel the request for ${kind}${name}`
|
return `Cancel the request for ${kind}${name}`;
|
||||||
: `Request ${kind}${name}`;
|
}
|
||||||
|
|
||||||
|
// A partly-held album is actionable *and* has a count, and
|
||||||
|
// the count does not survive being named after the action
|
||||||
|
// alone. The `partial` case below says why it matters — a
|
||||||
|
// ring says "some" to a sighted user and nothing to anyone
|
||||||
|
// else — and that argument does not stop applying because
|
||||||
|
// the badge became clickable. This branch used to say only
|
||||||
|
// "Request album X", so the one state the ring exists for
|
||||||
|
// was the one state whose name did not mention it.
|
||||||
|
if (this.status === 'partial') {
|
||||||
|
return `Request the rest of ${kind}${name} — ${this.owned} of ${this.expected} tracks are in your library`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `Request ${kind}${name}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (this.status) {
|
switch (this.status) {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { creditStore } from '@store/credit-store';
|
|||||||
import { FavoritesController } from '@store/controllers/favorites-controller';
|
import { FavoritesController } from '@store/controllers/favorites-controller';
|
||||||
import { designTokens } from '../../styles/tokens.css';
|
import { designTokens } from '../../styles/tokens.css';
|
||||||
import { srOnly } from '../../styles/sr-only.css';
|
import { srOnly } from '../../styles/sr-only.css';
|
||||||
|
import { ICON_QUEUE } from '@utils/icon-language';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* What is playing, at the size a phone has room for (plan 016 B2,
|
* What is playing, at the size a phone has room for (plan 016 B2,
|
||||||
@@ -339,7 +340,7 @@ export class NowPlayingView extends LitElement {
|
|||||||
aria-label="Show the queue"
|
aria-label="Show the queue"
|
||||||
@click=${this.openQueue}
|
@click=${this.openQueue}
|
||||||
>
|
>
|
||||||
<wa-icon name="list"></wa-icon>
|
<wa-icon name=${ICON_QUEUE}></wa-icon>
|
||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -70,6 +70,10 @@ import {
|
|||||||
} from '@utils/explore-link';
|
} from '@utils/explore-link';
|
||||||
import { designTokens } from '../../styles/tokens.css';
|
import { designTokens } from '../../styles/tokens.css';
|
||||||
import { list } from '@utils/binding';
|
import { list } from '@utils/binding';
|
||||||
|
import {
|
||||||
|
ICON_PLAYLIST,
|
||||||
|
ICON_QUEUE,
|
||||||
|
} from '@utils/icon-language';
|
||||||
|
|
||||||
/** One playlist row: the track and its position in the *playlist*,
|
/** One playlist row: the track and its position in the *playlist*,
|
||||||
* which is not its position in the filtered view. */
|
* which is not its position in the filtered view. */
|
||||||
@@ -1358,7 +1362,7 @@ export class PlaylistDetails
|
|||||||
</button>
|
</button>
|
||||||
<div class="playlist-avatar">
|
<div class="playlist-avatar">
|
||||||
<wa-icon
|
<wa-icon
|
||||||
name="list"
|
name=${ICON_PLAYLIST}
|
||||||
></wa-icon>
|
></wa-icon>
|
||||||
</div>
|
</div>
|
||||||
<div class="playlist-info">
|
<div class="playlist-info">
|
||||||
@@ -1665,7 +1669,7 @@ export class PlaylistDetails
|
|||||||
>
|
>
|
||||||
<wa-icon
|
<wa-icon
|
||||||
slot="icon"
|
slot="icon"
|
||||||
name="plus"
|
name=${ICON_QUEUE}
|
||||||
></wa-icon>
|
></wa-icon>
|
||||||
Add to Queue
|
Add to Queue
|
||||||
</wa-dropdown-item>
|
</wa-dropdown-item>
|
||||||
@@ -1720,7 +1724,7 @@ export class PlaylistDetails
|
|||||||
>
|
>
|
||||||
<wa-icon
|
<wa-icon
|
||||||
slot="icon"
|
slot="icon"
|
||||||
name="plus"
|
name=${ICON_PLAYLIST}
|
||||||
></wa-icon>
|
></wa-icon>
|
||||||
Add to Playlist
|
Add to Playlist
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { notificationStore } from '@store/notification-store';
|
|||||||
import { describeError } from '@utils/describe-error';
|
import { describeError } from '@utils/describe-error';
|
||||||
import type { DuplicateTracksDialog } from '@components/duplicate-tracks-dialog/duplicate-tracks-dialog.js';
|
import type { DuplicateTracksDialog } from '@components/duplicate-tracks-dialog/duplicate-tracks-dialog.js';
|
||||||
import { list } from '@utils/binding';
|
import { list } from '@utils/binding';
|
||||||
|
import { ICON_NEW } from '@utils/icon-language';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A reusable playlist picker that displays existing playlists
|
* A reusable playlist picker that displays existing playlists
|
||||||
@@ -307,7 +308,7 @@ export class PlaylistPicker extends LitElement {
|
|||||||
`
|
`
|
||||||
: nothing}
|
: nothing}
|
||||||
<wa-dropdown-item @click=${this.handleShowCreate}>
|
<wa-dropdown-item @click=${this.handleShowCreate}>
|
||||||
<wa-icon slot="icon" name="plus"></wa-icon>
|
<wa-icon slot="icon" name=${ICON_NEW}></wa-icon>
|
||||||
New Playlist
|
New Playlist
|
||||||
</wa-dropdown-item>
|
</wa-dropdown-item>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -37,6 +37,10 @@ import { ViewLifecycleMixin } from '@utils/view-lifecycle';
|
|||||||
import { FavoritesController } from '@store/controllers/favorites-controller';
|
import { FavoritesController } from '@store/controllers/favorites-controller';
|
||||||
import '@components/duplicate-tracks-dialog/duplicate-tracks-dialog.js';
|
import '@components/duplicate-tracks-dialog/duplicate-tracks-dialog.js';
|
||||||
import type { DuplicateTracksDialog } from '@components/duplicate-tracks-dialog/duplicate-tracks-dialog.js';
|
import type { DuplicateTracksDialog } from '@components/duplicate-tracks-dialog/duplicate-tracks-dialog.js';
|
||||||
|
import {
|
||||||
|
ICON_NEW,
|
||||||
|
ICON_PLAYLIST,
|
||||||
|
} from '@utils/icon-language';
|
||||||
|
|
||||||
const SCROLL_DEBOUNCE_MS = 100;
|
const SCROLL_DEBOUNCE_MS = 100;
|
||||||
|
|
||||||
@@ -1496,7 +1500,7 @@ export class PlaylistView extends ViewLifecycleMixin(LitElement) {
|
|||||||
@dragleave=${this.onNewButtonDragLeave}
|
@dragleave=${this.onNewButtonDragLeave}
|
||||||
@drop=${this.onNewButtonDrop}
|
@drop=${this.onNewButtonDrop}
|
||||||
>
|
>
|
||||||
<wa-icon name="plus"></wa-icon>
|
<wa-icon name=${ICON_NEW}></wa-icon>
|
||||||
New Playlist
|
New Playlist
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@@ -1641,10 +1645,10 @@ export class PlaylistView extends ViewLifecycleMixin(LitElement) {
|
|||||||
>
|
>
|
||||||
<div class="drop-zone-icon">
|
<div class="drop-zone-icon">
|
||||||
<wa-icon
|
<wa-icon
|
||||||
name="plus"
|
name=${ICON_NEW}
|
||||||
></wa-icon>
|
></wa-icon>
|
||||||
</div>
|
</div>
|
||||||
<wa-icon name="list"></wa-icon>
|
<wa-icon name=${ICON_PLAYLIST}></wa-icon>
|
||||||
<p>No playlists yet</p>
|
<p>No playlists yet</p>
|
||||||
<p style="font-size: 12px;">
|
<p style="font-size: 12px;">
|
||||||
Create a playlist or drop
|
Create a playlist or drop
|
||||||
@@ -1666,7 +1670,7 @@ export class PlaylistView extends ViewLifecycleMixin(LitElement) {
|
|||||||
>
|
>
|
||||||
<div class="drop-zone-icon">
|
<div class="drop-zone-icon">
|
||||||
<wa-icon
|
<wa-icon
|
||||||
name="plus"
|
name=${ICON_NEW}
|
||||||
></wa-icon>
|
></wa-icon>
|
||||||
</div>
|
</div>
|
||||||
<p>
|
<p>
|
||||||
@@ -1701,7 +1705,7 @@ export class PlaylistView extends ViewLifecycleMixin(LitElement) {
|
|||||||
>
|
>
|
||||||
<div class="drop-zone-icon">
|
<div class="drop-zone-icon">
|
||||||
<wa-icon
|
<wa-icon
|
||||||
name="plus"
|
name=${ICON_NEW}
|
||||||
></wa-icon>
|
></wa-icon>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -60,6 +60,11 @@ import {
|
|||||||
trackLink,
|
trackLink,
|
||||||
exploreLinkStyles,
|
exploreLinkStyles,
|
||||||
} from '@utils/explore-link';
|
} from '@utils/explore-link';
|
||||||
|
import {
|
||||||
|
ICON_NEW,
|
||||||
|
ICON_PLAYLIST,
|
||||||
|
ICON_QUEUE,
|
||||||
|
} from '@utils/icon-language';
|
||||||
/** Above this many tracks, clearing the queue asks first. */
|
/** Above this many tracks, clearing the queue asks first. */
|
||||||
const CLEAR_CONFIRM_THRESHOLD = 20;
|
const CLEAR_CONFIRM_THRESHOLD = 20;
|
||||||
|
|
||||||
@@ -67,6 +72,24 @@ const MIN_WIDTH = 200;
|
|||||||
const MAX_WIDTH = 500;
|
const MAX_WIDTH = 500;
|
||||||
const DEFAULT_WIDTH = 320;
|
const DEFAULT_WIDTH = 320;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The narrowest main panel the queue is allowed to leave behind before
|
||||||
|
* it stops being a column and becomes an overlay (plan 018, issue #24).
|
||||||
|
*
|
||||||
|
* There is no cliff to derive this from, and pretending otherwise would
|
||||||
|
* be the more dishonest answer: the track list rescales its columns
|
||||||
|
* continuously (213px down to 124px between main widths of 900 and 544,
|
||||||
|
* with no row overflow at any of them) and the album grid steps 3
|
||||||
|
* columns to 2 without breaking. So this is a judgement, anchored at
|
||||||
|
* both ends — it keeps the *default* 1100px window inline, because an
|
||||||
|
* inline queue is a desktop affordance people choose and demoting the
|
||||||
|
* common case to an overlay would be a regression in feel; and it puts
|
||||||
|
* every case measured as broken on the overlay side, which is 900x600
|
||||||
|
* (main = 379px, where all three of the Playlists header's actions are
|
||||||
|
* clipped) and every phone width (main = 69px at 390, 0px at 320).
|
||||||
|
*/
|
||||||
|
const MAIN_PANEL_FLOOR = 480;
|
||||||
|
|
||||||
@customElement('queue-panel')
|
@customElement('queue-panel')
|
||||||
export class QueuePanel
|
export class QueuePanel
|
||||||
extends LitElement
|
extends LitElement
|
||||||
@@ -80,6 +103,22 @@ export class QueuePanel
|
|||||||
@property({ type: Boolean, reflect: true })
|
@property({ type: Boolean, reflect: true })
|
||||||
open = false;
|
open = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the panel is covering the content instead of sitting
|
||||||
|
* beside it. **Computed, never set by a caller** — it is reflected
|
||||||
|
* so the stylesheet and a spec can both read it.
|
||||||
|
*
|
||||||
|
* It is deliberately *not* a media query, which is the whole reason
|
||||||
|
* this is a property and not a `@media` block. The panel's width is
|
||||||
|
* user state: drag-resizable between MIN_WIDTH and MAX_WIDTH and
|
||||||
|
* persisted. A breakpoint at a fixed viewport width silently
|
||||||
|
* assumes the default 320, so it is wrong by up to 180px for a user
|
||||||
|
* who has widened the panel — in the direction that hurts, since a
|
||||||
|
* wider queue is exactly when the content can least afford it.
|
||||||
|
*/
|
||||||
|
@property({ type: Boolean, reflect: true })
|
||||||
|
overlay = false;
|
||||||
|
|
||||||
@state()
|
@state()
|
||||||
private isDragging = false;
|
private isDragging = false;
|
||||||
|
|
||||||
@@ -185,6 +224,19 @@ export class QueuePanel
|
|||||||
private panelWidth = DEFAULT_WIDTH;
|
private panelWidth = DEFAULT_WIDTH;
|
||||||
private scrollbarDragging = false;
|
private scrollbarDragging = false;
|
||||||
|
|
||||||
|
/** Watches `.content-area`, which is the viewport minus the sidebar. */
|
||||||
|
private spaceObserver?: ResizeObserver;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What had focus when the overlay opened, so Escape and the scrim
|
||||||
|
* can give it back. Focus is only taken back if the panel had it —
|
||||||
|
* the same rule `MenuKeyboard` follows, for the same reason: the
|
||||||
|
* queue can also be closed by the button in the bottom bar, and
|
||||||
|
* yanking focus away from wherever the user actually is would be
|
||||||
|
* worse than leaving it.
|
||||||
|
*/
|
||||||
|
private overlayOpener: HTMLElement | null = null;
|
||||||
|
|
||||||
// _itemSize is an internal property applied via Object.assign in BaseLayout's
|
// _itemSize is an internal property applied via Object.assign in BaseLayout's
|
||||||
// config setter. Setting it to match the actual fixed .track-item height (49px)
|
// config setter. Setting it to match the actual fixed .track-item height (49px)
|
||||||
// prevents lit-virtualizer's scroll error correction from fighting the native
|
// prevents lit-virtualizer's scroll error correction from fighting the native
|
||||||
@@ -260,6 +312,86 @@ export class QueuePanel
|
|||||||
border-left: 1px solid var(--yj-border-subtle, #333);
|
border-left: 1px solid var(--yj-border-subtle, #333);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------
|
||||||
|
Overlay mode (plan 018, #24).
|
||||||
|
|
||||||
|
In flow the panel takes its width *from the main panel*,
|
||||||
|
which is the reported bug: at 900x600 that left 379px and
|
||||||
|
clipped every action in the Playlists header, and at 320px
|
||||||
|
it left 0px — the content was not degraded but gone.
|
||||||
|
|
||||||
|
Here the host spans the whole content area instead and
|
||||||
|
stops being a layout participant, so the main panel keeps
|
||||||
|
its full width and the queue sits over it. The host itself
|
||||||
|
is transparent and click-through; the scrim and the panel
|
||||||
|
are what take pointer events. The containment drops paint,
|
||||||
|
which would otherwise clip the panel's own shadow.
|
||||||
|
--------------------------------------------------------- */
|
||||||
|
:host([overlay]) {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: auto;
|
||||||
|
background-color: transparent;
|
||||||
|
overflow: visible;
|
||||||
|
pointer-events: none;
|
||||||
|
contain: layout style;
|
||||||
|
z-index: 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Closed, an overlay is not there at all. In flow the panel is
|
||||||
|
width: 0, which is its own way of saying this; absolutely
|
||||||
|
positioned there is no width to collapse. */
|
||||||
|
:host([overlay]:not([open])) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host([overlay][open]) {
|
||||||
|
border-left: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host([overlay]) .panel-content {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: var(--queue-width, ${unsafeCSS(DEFAULT_WIDTH)}px);
|
||||||
|
max-width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
background-color: var(--yj-bg-surface, #212529);
|
||||||
|
border-left: 1px solid var(--yj-border-subtle, #333);
|
||||||
|
box-shadow: -8px 0 24px rgb(0 0 0 / 45%);
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dragging the edge of something that is already covering the
|
||||||
|
content answers a question nobody asked, and it is a
|
||||||
|
mouse-only affordance either way. */
|
||||||
|
:host([overlay]) .resize-handle {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrim {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background-color: rgb(0 0 0 / 45%);
|
||||||
|
pointer-events: auto;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The phone gets the whole width: below 600 there is no
|
||||||
|
"beside" left to be, and this is the shape #55 turns into a
|
||||||
|
real screen. A media query inside a shadow root is answered
|
||||||
|
by the viewport, so the component states this itself rather
|
||||||
|
than the shell reaching in. */
|
||||||
|
@media (max-width: 599px) {
|
||||||
|
:host([overlay]) .panel-content {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.resize-handle {
|
.resize-handle {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
@@ -651,6 +783,20 @@ export class QueuePanel
|
|||||||
'--queue-width',
|
'--queue-width',
|
||||||
`${this.panelWidth}px`,
|
`${this.panelWidth}px`,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// The mode is a measurement, so it is observed rather than
|
||||||
|
// computed once: the parent is `.content-area`, whose width is
|
||||||
|
// the viewport minus the sidebar — including the sidebar's own
|
||||||
|
// collapse at 900px, which is what makes 900 the *worst*
|
||||||
|
// desktop width rather than the minimum.
|
||||||
|
this.updateOverlayMode();
|
||||||
|
|
||||||
|
if (this.parentElement) {
|
||||||
|
this.spaceObserver = new ResizeObserver(() =>
|
||||||
|
this.updateOverlayMode(),
|
||||||
|
);
|
||||||
|
this.spaceObserver.observe(this.parentElement);
|
||||||
|
}
|
||||||
document.addEventListener(
|
document.addEventListener(
|
||||||
'mousemove',
|
'mousemove',
|
||||||
this.handleMouseMove,
|
this.handleMouseMove,
|
||||||
@@ -685,6 +831,9 @@ export class QueuePanel
|
|||||||
super.disconnectedCallback();
|
super.disconnectedCallback();
|
||||||
this.creditsUnsub?.();
|
this.creditsUnsub?.();
|
||||||
this.creditsUnsub = undefined;
|
this.creditsUnsub = undefined;
|
||||||
|
this.spaceObserver?.disconnect();
|
||||||
|
this.spaceObserver = undefined;
|
||||||
|
document.removeEventListener('keydown', this.onOverlayKeydown);
|
||||||
document.removeEventListener(
|
document.removeEventListener(
|
||||||
'mousemove',
|
'mousemove',
|
||||||
this.handleMouseMove,
|
this.handleMouseMove,
|
||||||
@@ -731,7 +880,79 @@ export class QueuePanel
|
|||||||
this.delegationAttached = false;
|
this.delegationAttached = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decide whether the queue can afford to be a column.
|
||||||
|
*
|
||||||
|
* The parent is `.content-area`, so its width is the viewport minus
|
||||||
|
* the sidebar and the sum already accounts for the sidebar's own
|
||||||
|
* collapse. It is stable across the panel's own open/closed state
|
||||||
|
* in both modes — in flow the panel is a child of that box, and as
|
||||||
|
* an overlay it is out of flow — so this cannot oscillate.
|
||||||
|
*/
|
||||||
|
private updateOverlayMode = () => {
|
||||||
|
const available = this.parentElement?.clientWidth ?? 0;
|
||||||
|
|
||||||
|
// Before layout there is nothing to measure, and answering 0 by
|
||||||
|
// flipping to overlay would show the scrim for a frame.
|
||||||
|
if (available === 0) return;
|
||||||
|
|
||||||
|
this.overlay = available - this.panelWidth < MAIN_PANEL_FLOOR;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escape closes a scrimmed overlay, which is the one keyboard rule
|
||||||
|
* every dialog in this app already follows.
|
||||||
|
*
|
||||||
|
* It is a document listener rather than a panel-scoped binding
|
||||||
|
* (`services/shortcut-scope.ts`) because it is not a *shortcut*: it
|
||||||
|
* is the dismissal of something covering the page, and it has to
|
||||||
|
* work while focus is still behind the scrim. It is attached only
|
||||||
|
* while the overlay is actually up and removed on close, so it is
|
||||||
|
* scoped to a state rather than being a permanent global. Nothing
|
||||||
|
* else binds Escape — the shortcut service only uses it to blur a
|
||||||
|
* text input.
|
||||||
|
*/
|
||||||
|
private onOverlayKeydown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key !== 'Escape' || !this.open || !this.overlay) return;
|
||||||
|
|
||||||
|
e.stopPropagation();
|
||||||
|
this.closeFromOverlay();
|
||||||
|
};
|
||||||
|
|
||||||
|
private closeFromOverlay = () => {
|
||||||
|
const hadFocus = this.contains(
|
||||||
|
document.activeElement as Node | null,
|
||||||
|
);
|
||||||
|
|
||||||
|
this.open = false;
|
||||||
|
|
||||||
|
if (hadFocus) {
|
||||||
|
const back =
|
||||||
|
this.overlayOpener ??
|
||||||
|
document.getElementById('queue-button');
|
||||||
|
|
||||||
|
back?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.overlayOpener = null;
|
||||||
|
};
|
||||||
|
|
||||||
override updated() {
|
override updated() {
|
||||||
|
// The overlay owns Escape only while it is up.
|
||||||
|
if (this.open && this.overlay) {
|
||||||
|
document.addEventListener('keydown', this.onOverlayKeydown);
|
||||||
|
|
||||||
|
this.overlayOpener ??=
|
||||||
|
document.activeElement instanceof HTMLElement &&
|
||||||
|
document.activeElement !== document.body
|
||||||
|
? document.activeElement
|
||||||
|
: null;
|
||||||
|
} else {
|
||||||
|
document.removeEventListener('keydown', this.onOverlayKeydown);
|
||||||
|
|
||||||
|
if (!this.open) this.overlayOpener = null;
|
||||||
|
}
|
||||||
|
|
||||||
// Closed, the panel is `width: 0` — which hides it from the eye
|
// Closed, the panel is `width: 0` — which hides it from the eye
|
||||||
// and from nobody else: its Clear and Add buttons still took tab
|
// and from nobody else: its Clear and Add buttons still took tab
|
||||||
// stops at x=1440 and were still read out (H-5). `inert` is the
|
// stops at x=1440 and were still read out (H-5). `inert` is the
|
||||||
@@ -1626,6 +1847,11 @@ export class QueuePanel
|
|||||||
'--queue-width',
|
'--queue-width',
|
||||||
`${clampedWidth}px`,
|
`${clampedWidth}px`,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Widening the panel is one of the two ways the content can run
|
||||||
|
// out of room, and it is the way a viewport-width media query
|
||||||
|
// cannot see at all.
|
||||||
|
this.updateOverlayMode();
|
||||||
};
|
};
|
||||||
|
|
||||||
private handleMouseUp = () => {
|
private handleMouseUp = () => {
|
||||||
@@ -1709,6 +1935,15 @@ export class QueuePanel
|
|||||||
const tracks = this.queue.tracks;
|
const tracks = this.queue.tracks;
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
|
${this.overlay
|
||||||
|
? html`<div
|
||||||
|
class="scrim"
|
||||||
|
part="scrim"
|
||||||
|
data-testid="queue-scrim"
|
||||||
|
aria-hidden="true"
|
||||||
|
@click=${this.closeFromOverlay}
|
||||||
|
></div>`
|
||||||
|
: nothing}
|
||||||
<div class="panel-content">
|
<div class="panel-content">
|
||||||
<div
|
<div
|
||||||
class="resize-handle ${this.isDragging
|
class="resize-handle ${this.isDragging
|
||||||
@@ -1755,9 +1990,19 @@ export class QueuePanel
|
|||||||
title="Add queue to playlist"
|
title="Add queue to playlist"
|
||||||
>
|
>
|
||||||
<wa-icon
|
<wa-icon
|
||||||
name="plus"
|
name=${ICON_PLAYLIST}
|
||||||
></wa-icon>
|
></wa-icon>
|
||||||
</button>
|
</button>
|
||||||
|
${this.overlay
|
||||||
|
? html`<button
|
||||||
|
class="header-action-button"
|
||||||
|
data-testid="queue-close"
|
||||||
|
aria-label="Close queue"
|
||||||
|
@click=${this.closeFromOverlay}
|
||||||
|
>
|
||||||
|
<wa-icon name="xmark"></wa-icon>
|
||||||
|
</button>`
|
||||||
|
: nothing}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1794,11 +2039,11 @@ export class QueuePanel
|
|||||||
? html`<div class="empty-state">
|
? html`<div class="empty-state">
|
||||||
<div class="drop-zone-icon">
|
<div class="drop-zone-icon">
|
||||||
<wa-icon
|
<wa-icon
|
||||||
name="plus"
|
name=${ICON_NEW}
|
||||||
></wa-icon>
|
></wa-icon>
|
||||||
</div>
|
</div>
|
||||||
<wa-icon
|
<wa-icon
|
||||||
name="list"
|
name=${ICON_QUEUE}
|
||||||
></wa-icon>
|
></wa-icon>
|
||||||
<p>Queue is empty</p>
|
<p>Queue is empty</p>
|
||||||
<p style="font-size: 12px;">
|
<p style="font-size: 12px;">
|
||||||
@@ -1875,7 +2120,7 @@ export class QueuePanel
|
|||||||
>
|
>
|
||||||
<wa-icon
|
<wa-icon
|
||||||
slot="icon"
|
slot="icon"
|
||||||
name="plus"
|
name=${ICON_PLAYLIST}
|
||||||
></wa-icon>
|
></wa-icon>
|
||||||
Add to Playlist
|
Add to Playlist
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -4,6 +4,11 @@ import '@awesome.me/webawesome/dist/components/icon/icon.js';
|
|||||||
import { designTokens } from '../../styles/tokens.css';
|
import { designTokens } from '../../styles/tokens.css';
|
||||||
|
|
||||||
import type { DragActiveDetail } from '@utils/drag-controller';
|
import type { DragActiveDetail } from '@utils/drag-controller';
|
||||||
|
import {
|
||||||
|
ICON_PLAYLIST,
|
||||||
|
ICON_AUTOTAG,
|
||||||
|
ICON_REQUESTED,
|
||||||
|
} from '@utils/icon-language';
|
||||||
|
|
||||||
type View = 'home' | 'playlists' | 'artists' | 'genres' | 'albums' | 'tracks' | 'explore' | 'downloads' | 'autotag' | 'jobs' | 'settings';
|
type View = 'home' | 'playlists' | 'artists' | 'genres' | 'albums' | 'tracks' | 'explore' | 'downloads' | 'autotag' | 'jobs' | 'settings';
|
||||||
|
|
||||||
@@ -197,14 +202,14 @@ export class AppSidebar extends LitElement {
|
|||||||
|
|
||||||
private navItems: NavItem[] = [
|
private navItems: NavItem[] = [
|
||||||
{ id: 'home', label: 'Home', icon: 'house' },
|
{ id: 'home', label: 'Home', icon: 'house' },
|
||||||
{ id: 'playlists', label: 'Playlists', icon: 'list' },
|
{ id: 'playlists', label: 'Playlists', icon: ICON_PLAYLIST },
|
||||||
{ id: 'artists', label: 'Artists', icon: 'user-group' },
|
{ id: 'artists', label: 'Artists', icon: 'user-group' },
|
||||||
{ id: 'genres', label: 'Genres', icon: 'masks-theater' },
|
{ id: 'genres', label: 'Genres', icon: 'masks-theater' },
|
||||||
{ id: 'albums', label: 'Albums', icon: 'compact-disc' },
|
{ id: 'albums', label: 'Albums', icon: 'compact-disc' },
|
||||||
{ id: 'tracks', label: 'Tracks', icon: 'music' },
|
{ id: 'tracks', label: 'Tracks', icon: 'music' },
|
||||||
{ id: 'explore', label: 'Explore', icon: 'globe' },
|
{ id: 'explore', label: 'Explore', icon: 'globe' },
|
||||||
{ id: 'downloads', label: 'Downloads', icon: 'bookmark' },
|
{ id: 'downloads', label: 'Downloads', icon: ICON_REQUESTED },
|
||||||
{ id: 'autotag', label: 'Autotag', icon: 'tag' },
|
{ id: 'autotag', label: 'Autotag', icon: ICON_AUTOTAG },
|
||||||
{ id: 'jobs', label: 'Jobs', icon: 'list-check' },
|
{ id: 'jobs', label: 'Jobs', icon: 'list-check' },
|
||||||
{ id: 'settings', label: 'Settings', icon: 'gear' },
|
{ id: 'settings', label: 'Settings', icon: 'gear' },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -61,6 +61,10 @@ import {
|
|||||||
import '@components/smart-playlist-editor/smart-playlist-editor.js';
|
import '@components/smart-playlist-editor/smart-playlist-editor.js';
|
||||||
import { designTokens } from '../../styles/tokens.css';
|
import { designTokens } from '../../styles/tokens.css';
|
||||||
import { list } from '@utils/binding';
|
import { list } from '@utils/binding';
|
||||||
|
import {
|
||||||
|
ICON_PLAYLIST,
|
||||||
|
ICON_QUEUE,
|
||||||
|
} from '@utils/icon-language';
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1481,7 +1485,7 @@ export class SmartPlaylistDetails
|
|||||||
>
|
>
|
||||||
<wa-icon
|
<wa-icon
|
||||||
slot="icon"
|
slot="icon"
|
||||||
name="plus"
|
name=${ICON_QUEUE}
|
||||||
></wa-icon>
|
></wa-icon>
|
||||||
Add to Queue
|
Add to Queue
|
||||||
</wa-dropdown-item>
|
</wa-dropdown-item>
|
||||||
@@ -1521,7 +1525,7 @@ export class SmartPlaylistDetails
|
|||||||
>
|
>
|
||||||
<wa-icon
|
<wa-icon
|
||||||
slot="icon"
|
slot="icon"
|
||||||
name="plus"
|
name=${ICON_PLAYLIST}
|
||||||
></wa-icon>
|
></wa-icon>
|
||||||
Add to Playlist
|
Add to Playlist
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -11,8 +11,16 @@ import '../library-status-indicator/library-status-indicator.js';
|
|||||||
import type { LibraryStatus } from '../library-status-indicator/library-status-indicator.js';
|
import type { LibraryStatus } from '../library-status-indicator/library-status-indicator.js';
|
||||||
import { creditLink, exploreLinkStyles } from '../../utils/explore-link';
|
import { creditLink, exploreLinkStyles } from '../../utils/explore-link';
|
||||||
import { creditStore } from '@store/credit-store';
|
import { creditStore } from '@store/credit-store';
|
||||||
import { libraryStatusFor } from '../../utils/library-status';
|
import { albumBadgeFor, libraryStatusFor } from '../../utils/library-status';
|
||||||
|
import {
|
||||||
|
isOwned,
|
||||||
|
ownershipLabel,
|
||||||
|
unownedStyles,
|
||||||
|
type OwnableKind,
|
||||||
|
} from '../../utils/ownership';
|
||||||
|
import { completenessStore } from '../../store/completeness-store';
|
||||||
import { downloadStore } from '../../store/download-store';
|
import { downloadStore } from '../../store/download-store';
|
||||||
|
import { classMap } from 'lit/directives/class-map.js';
|
||||||
|
|
||||||
/** Format milliseconds as m:ss. */
|
/** Format milliseconds as m:ss. */
|
||||||
function formatDuration(ms: number | undefined): string {
|
function formatDuration(ms: number | undefined): string {
|
||||||
@@ -65,6 +73,9 @@ export class TopResultsRow extends LitElement {
|
|||||||
/** Unsubscribes the credit-arrival repaint. */
|
/** Unsubscribes the credit-arrival repaint. */
|
||||||
private creditsUnsub?: () => void;
|
private creditsUnsub?: () => void;
|
||||||
|
|
||||||
|
/** Unsubscribes the "how much of this album is here" repaint. */
|
||||||
|
private unsubCompleteness?: () => void;
|
||||||
|
|
||||||
override connectedCallback(): void {
|
override connectedCallback(): void {
|
||||||
super.connectedCallback();
|
super.connectedCallback();
|
||||||
|
|
||||||
@@ -74,6 +85,9 @@ export class TopResultsRow extends LitElement {
|
|||||||
this.unsubRequests = downloadStore.subscribe(() =>
|
this.unsubRequests = downloadStore.subscribe(() =>
|
||||||
this.requestUpdate(),
|
this.requestUpdate(),
|
||||||
);
|
);
|
||||||
|
this.unsubCompleteness = completenessStore.subscribe(() =>
|
||||||
|
this.requestUpdate(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
override disconnectedCallback(): void {
|
override disconnectedCallback(): void {
|
||||||
@@ -81,12 +95,15 @@ export class TopResultsRow extends LitElement {
|
|||||||
this.creditsUnsub = undefined;
|
this.creditsUnsub = undefined;
|
||||||
this.unsubRequests?.();
|
this.unsubRequests?.();
|
||||||
this.unsubRequests = undefined;
|
this.unsubRequests = undefined;
|
||||||
|
this.unsubCompleteness?.();
|
||||||
|
this.unsubCompleteness = undefined;
|
||||||
super.disconnectedCallback();
|
super.disconnectedCallback();
|
||||||
}
|
}
|
||||||
|
|
||||||
static override styles = [
|
static override styles = [
|
||||||
designTokens,
|
designTokens,
|
||||||
exploreLinkStyles,
|
exploreLinkStyles,
|
||||||
|
unownedStyles,
|
||||||
css`
|
css`
|
||||||
:host {
|
:host {
|
||||||
display: block;
|
display: block;
|
||||||
@@ -289,23 +306,41 @@ export class TopResultsRow extends LitElement {
|
|||||||
? r.year || ''
|
? r.year || ''
|
||||||
: formatDuration(r.length) || '';
|
: formatDuration(r.length) || '';
|
||||||
|
|
||||||
const status: LibraryStatus = libraryStatusFor(
|
const entityType: OwnableKind =
|
||||||
Boolean(r.inLibrary),
|
|
||||||
r.mbid,
|
|
||||||
);
|
|
||||||
const entityType: 'artist' | 'album' | 'track' =
|
|
||||||
r.entityType === 'artist'
|
r.entityType === 'artist'
|
||||||
? 'artist'
|
? 'artist'
|
||||||
: r.entityType === 'release_group'
|
: r.entityType === 'release_group'
|
||||||
? 'album'
|
? 'album'
|
||||||
: 'track';
|
: 'track';
|
||||||
|
|
||||||
|
// Ownership is the local row, not the catalog's flag — see
|
||||||
|
// `utils/ownership.ts`. An album additionally says *how much*
|
||||||
|
// of it is here, which is the one thing a tick cannot.
|
||||||
|
const owned = isOwned(r);
|
||||||
|
const badge =
|
||||||
|
entityType === 'album'
|
||||||
|
? albumBadgeFor(r, r.mbid)
|
||||||
|
: {
|
||||||
|
status: libraryStatusFor(owned, r.mbid) as LibraryStatus,
|
||||||
|
owned: 0,
|
||||||
|
expected: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
// A card navigates whether or not the entity is owned, so it is
|
||||||
|
// not `aria-disabled` the way an unplayable track row is — the
|
||||||
|
// name is what carries the state to anyone not seeing the
|
||||||
|
// dimming.
|
||||||
return html`
|
return html`
|
||||||
<div
|
<div
|
||||||
class="card"
|
class=${classMap({ card: true, unowned: !owned })}
|
||||||
role="button"
|
role="button"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
aria-label=${`${badgeLabel(r.entityType)}: ${r.name}`}
|
aria-label=${ownershipLabel(
|
||||||
|
owned,
|
||||||
|
`${badgeLabel(r.entityType)}:`,
|
||||||
|
r.name,
|
||||||
|
entityType,
|
||||||
|
)}
|
||||||
@click=${() => this.handleClick(r)}
|
@click=${() => this.handleClick(r)}
|
||||||
@keydown=${(e: KeyboardEvent) => {
|
@keydown=${(e: KeyboardEvent) => {
|
||||||
if (e.key !== 'Enter' && e.key !== ' ') return;
|
if (e.key !== 'Enter' && e.key !== ' ') return;
|
||||||
@@ -345,10 +380,12 @@ export class TopResultsRow extends LitElement {
|
|||||||
: nothing}
|
: nothing}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
${isArtist
|
${isArtist || badge.status === 'in-library'
|
||||||
? nothing
|
? nothing
|
||||||
: html`<library-status-indicator
|
: html`<library-status-indicator
|
||||||
status=${status}
|
status=${badge.status}
|
||||||
|
owned=${badge.owned}
|
||||||
|
expected=${badge.expected}
|
||||||
entity-type=${entityType}
|
entity-type=${entityType}
|
||||||
label=${r.name}
|
label=${r.name}
|
||||||
request-mbid=${r.mbid}
|
request-mbid=${r.mbid}
|
||||||
|
|||||||
@@ -74,6 +74,10 @@ import { tracksByFilePath, tracksForPaths } from '@utils/track-index.js';
|
|||||||
import '@components/playlist-picker/playlist-picker.js';
|
import '@components/playlist-picker/playlist-picker.js';
|
||||||
import type { TrackDetails } from '@components/track-details/track-details.js';
|
import type { TrackDetails } from '@components/track-details/track-details.js';
|
||||||
import type { CoverArtUrls } from '@components/track-details/track-details.js';
|
import type { CoverArtUrls } from '@components/track-details/track-details.js';
|
||||||
|
import {
|
||||||
|
ICON_PLAYLIST,
|
||||||
|
ICON_QUEUE,
|
||||||
|
} from '@utils/icon-language';
|
||||||
|
|
||||||
const COLUMN_STORAGE_KEY = 'track-list-column-widths';
|
const COLUMN_STORAGE_KEY = 'track-list-column-widths';
|
||||||
const SORT_FIELD_KEY = 'track-list-sort-field';
|
const SORT_FIELD_KEY = 'track-list-sort-field';
|
||||||
@@ -2342,7 +2346,7 @@ export class TrackList
|
|||||||
@click=${() => this.onContextMenuAction('add-to-queue')}
|
@click=${() => this.onContextMenuAction('add-to-queue')}
|
||||||
@mouseenter=${() => this.ctxMenu.closePlaylistSubmenu()}
|
@mouseenter=${() => this.ctxMenu.closePlaylistSubmenu()}
|
||||||
>
|
>
|
||||||
<wa-icon slot="icon" name="plus"></wa-icon>
|
<wa-icon slot="icon" name=${ICON_QUEUE}></wa-icon>
|
||||||
Add to Queue
|
Add to Queue
|
||||||
</wa-dropdown-item>
|
</wa-dropdown-item>
|
||||||
<wa-dropdown-item
|
<wa-dropdown-item
|
||||||
@@ -2364,7 +2368,7 @@ export class TrackList
|
|||||||
void this.ctxMenu.showPlaylistSubmenu(this.selection.getSelectedKeysOrdered());
|
void this.ctxMenu.showPlaylistSubmenu(this.selection.getSelectedKeysOrdered());
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<wa-icon slot="icon" name="plus"></wa-icon>
|
<wa-icon slot="icon" name=${ICON_PLAYLIST}></wa-icon>
|
||||||
Add to Playlist
|
Add to Playlist
|
||||||
<span class="submenu-arrow">▶</span>
|
<span class="submenu-arrow">▶</span>
|
||||||
</wa-dropdown-item>
|
</wa-dropdown-item>
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ solid/arrows-rotate
|
|||||||
solid/arrow-up-short-wide
|
solid/arrow-up-short-wide
|
||||||
solid/backward-step
|
solid/backward-step
|
||||||
solid/bars
|
solid/bars
|
||||||
|
solid/bars-staggered
|
||||||
regular/bookmark
|
regular/bookmark
|
||||||
solid/bookmark
|
solid/bookmark
|
||||||
solid/box-open
|
solid/box-open
|
||||||
|
|||||||
@@ -0,0 +1,208 @@
|
|||||||
|
/**
|
||||||
|
* How much of an album is here, keyed by local album id.
|
||||||
|
*
|
||||||
|
* The album page has always been able to say "9 of 12"; a card could
|
||||||
|
* not, so an album held two tracks of ten wore a plain green tick on
|
||||||
|
* every grid in the app — which is the complaint the badge-accuracy
|
||||||
|
* issue was filed about, one surface over. A ring with a count needs a
|
||||||
|
* numerator and a denominator per album, and one `GetAlbumCompleteness`
|
||||||
|
* per card is fifty round trips for a grid of fifty.
|
||||||
|
*
|
||||||
|
* So this is `credit-store` one question over, and for the same three
|
||||||
|
* reasons.
|
||||||
|
*
|
||||||
|
* **The lookup is batched and coalesced across callers.** `request()`
|
||||||
|
* is per-card, cheap and safe to call from a render: it collects into a
|
||||||
|
* pending set and flushes once on the next frame, which turns a
|
||||||
|
* screenful into exactly one `GetAlbumsCompleteness`. A frame rather
|
||||||
|
* than a microtask because the point is to collect every card an update
|
||||||
|
* pass renders, and those do not land inside one microtask checkpoint.
|
||||||
|
*
|
||||||
|
* **Absence is cached as an answer.** An album with no files at all is
|
||||||
|
* omitted by the backend deliberately — "I have none of this" and "I
|
||||||
|
* have no idea" are the third state `known` exists to keep apart — so a
|
||||||
|
* miss stored as a hit would re-ask about it forever. What is cached is
|
||||||
|
* *asked*, and the unknown answer is a first-class value.
|
||||||
|
*
|
||||||
|
* **It is invalidated rather than aged.** Completeness is a fact about
|
||||||
|
* files on disk, and the two events that change it — a scan finishing
|
||||||
|
* and tags being rewritten — are exactly the ones `library-store`
|
||||||
|
* already discards everything on. `TracksRemovedFromLibrary` is the
|
||||||
|
* third: it changes a numerator without a rescan.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { GetAlbumsCompleteness } from '@go/library/library.js';
|
||||||
|
import type * as library from '@go/library/models.js';
|
||||||
|
import { EventsOn } from '@runtime/runtime';
|
||||||
|
import { Events } from '../events';
|
||||||
|
import { LRUMap } from '../utils/lru-map';
|
||||||
|
import { compact } from '../utils/binding';
|
||||||
|
import { registerCacheProbe } from '../utils/cache-stats';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entries retained. Four small numbers each, so the bound is about
|
||||||
|
* unbounded growth rather than bytes — and it is set well above any one
|
||||||
|
* grid, since a cap below the visible count evicts cards that are still
|
||||||
|
* on screen and the re-render fetches them straight back.
|
||||||
|
*/
|
||||||
|
export const COMPLETENESS_CACHE_LIMIT = 20_000;
|
||||||
|
|
||||||
|
/** What the app asks of a cached entry. */
|
||||||
|
export type Completeness = library.AlbumCompleteness;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The answer for an album nobody has a file of.
|
||||||
|
*
|
||||||
|
* Not a zero-of-zero: `known` false is what every consumer already
|
||||||
|
* reads as "say nothing", and it is the same value the album page's
|
||||||
|
* `completenessAnswer()` treats as unknowable.
|
||||||
|
*/
|
||||||
|
const NOTHING_HERE: Completeness = {
|
||||||
|
owned: 0,
|
||||||
|
expected: 0,
|
||||||
|
known: false,
|
||||||
|
complete: false,
|
||||||
|
} as Completeness;
|
||||||
|
|
||||||
|
class CompletenessStore {
|
||||||
|
private cache = new LRUMap<number, Completeness>(COMPLETENESS_CACHE_LIMIT);
|
||||||
|
|
||||||
|
/** Ids with a request in flight, so a re-render does not refetch. */
|
||||||
|
private inFlight = new Set<number>();
|
||||||
|
|
||||||
|
private listeners = new Set<() => void>();
|
||||||
|
|
||||||
|
/** Collected by request(), flushed as one batch on the next frame. */
|
||||||
|
private pending = new Set<number>();
|
||||||
|
|
||||||
|
private flushHandle: number | null = null;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
registerCacheProbe('albumCompleteness', () => ({
|
||||||
|
entries: this.cache.size,
|
||||||
|
chars: this.cache.size * 4,
|
||||||
|
limit: COMPLETENESS_CACHE_LIMIT,
|
||||||
|
}));
|
||||||
|
|
||||||
|
EventsOn(Events.LibraryScanComplete, () => this.invalidate());
|
||||||
|
EventsOn(Events.TrackMetadataChanged, () => this.invalidate());
|
||||||
|
EventsOn(Events.TracksRemovedFromLibrary, () => this.invalidate());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe to "some answers arrived".
|
||||||
|
*
|
||||||
|
* Deliberately not per-album, for `credit-store`'s reason: a grid
|
||||||
|
* fetches its cards in one call and re-renders once, so a
|
||||||
|
* fine-grained signal would buy nothing and cost a listener a card.
|
||||||
|
*/
|
||||||
|
subscribe(fn: () => void): () => void {
|
||||||
|
this.listeners.add(fn);
|
||||||
|
|
||||||
|
return () => this.listeners.delete(fn);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The answer for one album, or undefined until it has been asked. */
|
||||||
|
get(albumID: number | undefined | null): Completeness | undefined {
|
||||||
|
if (!albumID || albumID <= 0) return undefined;
|
||||||
|
|
||||||
|
return this.cache.get(albumID);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ask about one album, joining whatever batch is forming.
|
||||||
|
*
|
||||||
|
* Safe from inside a render: a set insert and a scheduled flush,
|
||||||
|
* with anything cached or in flight dropped. It does not loop —
|
||||||
|
* after a flush every id asked for is cached, so the re-render's
|
||||||
|
* requests are all dropped and nothing notifies again.
|
||||||
|
*/
|
||||||
|
request(albumID: number | undefined | null): void {
|
||||||
|
if (!albumID || albumID <= 0) return;
|
||||||
|
if (this.cache.has(albumID)) return;
|
||||||
|
if (this.inFlight.has(albumID)) return;
|
||||||
|
if (this.pending.has(albumID)) return;
|
||||||
|
|
||||||
|
this.pending.add(albumID);
|
||||||
|
|
||||||
|
if (this.flushHandle !== null) return;
|
||||||
|
|
||||||
|
this.flushHandle = requestAnimationFrame(() => {
|
||||||
|
this.flushHandle = null;
|
||||||
|
|
||||||
|
const batch = [...this.pending];
|
||||||
|
|
||||||
|
this.pending.clear();
|
||||||
|
|
||||||
|
void this.ensure(batch);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ask and read in one call, for use inside a template.
|
||||||
|
*
|
||||||
|
* A getter with a side effect, deliberately — `credit-store` makes
|
||||||
|
* the same trade and for the same reason: the alternative is every
|
||||||
|
* call site writing `request(x)` beside `get(x)` and one of them
|
||||||
|
* eventually forgetting, which renders a permanently unknown
|
||||||
|
* completeness that looks exactly like an album with no totals.
|
||||||
|
*/
|
||||||
|
completeness(albumID: number | undefined | null): Completeness | undefined {
|
||||||
|
this.request(albumID);
|
||||||
|
|
||||||
|
return this.get(albumID);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fetch for a list, skipping anything known or already in flight. */
|
||||||
|
async ensure(albumIDs: readonly number[]): Promise<void> {
|
||||||
|
const wanted = new Set<number>();
|
||||||
|
|
||||||
|
for (const id of albumIDs) {
|
||||||
|
if (!id || id <= 0) continue;
|
||||||
|
// `has` rather than `get`: probing must not mark an entry
|
||||||
|
// recently-used, or scrolling past a card would keep it
|
||||||
|
// alive ahead of one actually being rendered.
|
||||||
|
if (this.cache.has(id)) continue;
|
||||||
|
if (this.inFlight.has(id)) continue;
|
||||||
|
|
||||||
|
wanted.add(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wanted.size === 0) return;
|
||||||
|
|
||||||
|
const batch = [...wanted];
|
||||||
|
|
||||||
|
for (const id of batch) this.inFlight.add(id);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const found = compact(await GetAlbumsCompleteness(batch));
|
||||||
|
|
||||||
|
for (const id of batch) {
|
||||||
|
this.cache.set(id, found[String(id)] ?? NOTHING_HERE);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.notify();
|
||||||
|
} catch (err) {
|
||||||
|
// A count is an enrichment: without it a card shows the
|
||||||
|
// plain "you have this", which is what it showed before and
|
||||||
|
// is a weaker answer rather than a broken one.
|
||||||
|
console.error('Failed to load album completeness', err);
|
||||||
|
} finally {
|
||||||
|
for (const id of batch) this.inFlight.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drop everything: the files on disk changed. */
|
||||||
|
invalidate(): void {
|
||||||
|
this.cache = new LRUMap<number, Completeness>(
|
||||||
|
COMPLETENESS_CACHE_LIMIT,
|
||||||
|
);
|
||||||
|
this.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
private notify(): void {
|
||||||
|
for (const fn of this.listeners) fn();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const completenessStore = new CompletenessStore();
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
/**
|
||||||
|
* What each icon in this app means, once.
|
||||||
|
*
|
||||||
|
* The set was a mix: `plus` meant "add to the queue", "add to a
|
||||||
|
* playlist", "make a new playlist" and "you do not own this" — the
|
||||||
|
* first two *adjacent in the same context menu* — while `list` meant
|
||||||
|
* the queue, the Playlists destination, and (in `queue-panel` alone)
|
||||||
|
* adding to the queue. Two icons carrying seven meanings between them
|
||||||
|
* is not a vocabulary, and a user cannot learn one that says four
|
||||||
|
* things.
|
||||||
|
*
|
||||||
|
* The rule these are chosen by: **an icon names the noun it acts on,
|
||||||
|
* not the verb.** "Add to queue" and "add to playlist" are the same
|
||||||
|
* verb on different nouns, so the noun is what has to differ — which is
|
||||||
|
* also why adding to a playlist wears the Playlists destination's own
|
||||||
|
* icon rather than a generic plus. `plus` survives for exactly the one
|
||||||
|
* thing it is unambiguous about, making something that did not exist.
|
||||||
|
*
|
||||||
|
* Import these rather than writing a name inline. A literal string is
|
||||||
|
* how the last set drifted, and nothing catches it: a wrong-but-real
|
||||||
|
* icon renders perfectly.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Start playing this now. */
|
||||||
|
export const ICON_PLAY = 'play';
|
||||||
|
|
||||||
|
/** Start playing this now, in a shuffled order. */
|
||||||
|
export const ICON_SHUFFLE = 'shuffle';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The queue, and putting something into it.
|
||||||
|
*
|
||||||
|
* One glyph for the noun and the action, so the button that opens the
|
||||||
|
* queue and the menu item that adds to it are visibly the same subject.
|
||||||
|
* The queue used to wear `list`, which is the Playlists destination.
|
||||||
|
*/
|
||||||
|
export const ICON_QUEUE = 'bars-staggered';
|
||||||
|
|
||||||
|
/** Put this next in the queue rather than at the end. */
|
||||||
|
export const ICON_PLAY_NEXT = 'forward-step';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A playlist, and adding something to one.
|
||||||
|
*
|
||||||
|
* The same icon as the Playlists destination in the sidebar, which is
|
||||||
|
* the point: the menu item says where the thing is going.
|
||||||
|
*/
|
||||||
|
export const ICON_PLAYLIST = 'list';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make a new thing that did not exist — a playlist, a rule, a library.
|
||||||
|
*
|
||||||
|
* This is the only meaning `plus` keeps. It used to carry four.
|
||||||
|
*/
|
||||||
|
export const ICON_NEW = 'plus';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The request ("want") toggle, as an outline/solid pair.
|
||||||
|
*
|
||||||
|
* Two states of one control have to read as each other's opposite,
|
||||||
|
* which a plus and a bookmark do not. The pair was already in the app
|
||||||
|
* and already correct — `explore-album-details`'s "Want this" button
|
||||||
|
* has used it since it was written, and `favorites-controller` uses the
|
||||||
|
* same shape for `regular/heart` → `heart` — while the badge forty
|
||||||
|
* pixels away showed a plus for the same state.
|
||||||
|
*
|
||||||
|
* That is `utils/library-status.ts`'s fault one layer down: it made the
|
||||||
|
* two surfaces agree on *what wanting means* and left them disagreeing
|
||||||
|
* on what it looks like.
|
||||||
|
*/
|
||||||
|
export const ICON_CAN_REQUEST = 'regular/bookmark';
|
||||||
|
export const ICON_REQUESTED = 'solid/bookmark';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* You have this.
|
||||||
|
*
|
||||||
|
* Deliberately not drawn on the common case — see the tracklist, where
|
||||||
|
* absence is what gets marked. This is for the places that answer the
|
||||||
|
* question directly, like the badge on a catalog card.
|
||||||
|
*/
|
||||||
|
export const ICON_IN_LIBRARY = 'check';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The autotagger, and a match it is offering.
|
||||||
|
*
|
||||||
|
* The same icon as the Autotag destination in the sidebar, on the rule
|
||||||
|
* `ICON_PLAYLIST` was chosen by: an icon names the noun it acts on, so
|
||||||
|
* a suggestion on the album page wears the mark of the page it would
|
||||||
|
* send you to. Governed from the moment there were two call sites,
|
||||||
|
* which is when a name stops being a detail of one component.
|
||||||
|
*/
|
||||||
|
export const ICON_AUTOTAG = 'tag';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Something is being fetched right now.
|
||||||
|
*
|
||||||
|
* Distinct from `ICON_REQUESTED`: a request may sit on the list
|
||||||
|
* forever without anything happening, which is exactly why the badge's
|
||||||
|
* "queued" state stopped being an hourglass.
|
||||||
|
*/
|
||||||
|
export const ICON_DOWNLOADING = 'download';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Take this away.
|
||||||
|
*
|
||||||
|
* One icon for removing from a playlist, from the queue and from the
|
||||||
|
* library, because the difference that matters is stated in the words
|
||||||
|
* beside it and in the confirmation — "Remove from Library" says in its
|
||||||
|
* impact line that the files are not deleted.
|
||||||
|
*/
|
||||||
|
export const ICON_REMOVE = 'trash';
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
|
import { completenessStore } from '@store/completeness-store';
|
||||||
import { downloadStore } from '@store/download-store';
|
import { downloadStore } from '@store/download-store';
|
||||||
import { libraryStore } from '@store/library-store';
|
import { libraryStore } from '@store/library-store';
|
||||||
import type * as download from '@go/download/models.js';
|
import type * as download from '@go/download/models.js';
|
||||||
import type { LibraryStatus } from '../components/library-status-indicator/library-status-indicator';
|
import type { LibraryStatus } from '../components/library-status-indicator/library-status-indicator';
|
||||||
|
import { isOwned, type Ownable } from './ownership';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* What the tick/hourglass/plus badge should say about one entity.
|
* What the tick/hourglass/plus badge should say about one entity.
|
||||||
@@ -46,6 +48,61 @@ export function libraryStatusFor(
|
|||||||
return 'not-in-library';
|
return 'not-in-library';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Everything a badge needs about one entity, decided in one place.
|
||||||
|
*
|
||||||
|
* `status` is the state; `owned`/`expected` are the counts behind
|
||||||
|
* `partial` and are zero for every other state, which is what the badge
|
||||||
|
* requires — it documents that a caller with no total must not pass a
|
||||||
|
* ring at 0%.
|
||||||
|
*/
|
||||||
|
export interface BadgeState {
|
||||||
|
status: LibraryStatus;
|
||||||
|
owned: number;
|
||||||
|
expected: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the badge on an album card should say.
|
||||||
|
*
|
||||||
|
* Three rules, and the middle one is the whole point of this issue.
|
||||||
|
*
|
||||||
|
* **Ownership is the local album id**, per `utils/ownership.ts` — a
|
||||||
|
* file, not the catalog's `inLibrary` ratchet.
|
||||||
|
*
|
||||||
|
* **A partly-held album says how partly.** The count comes from
|
||||||
|
* `completenessStore`, which batches a screenful into one query;
|
||||||
|
* reading it is what asks for it. Before this, an album held 2 tracks
|
||||||
|
* of 10 wore the same green tick as one held whole on every grid in
|
||||||
|
* the app.
|
||||||
|
*
|
||||||
|
* **A total that was never declared is not a total of zero.** Where
|
||||||
|
* `known` is false — most of an untagged library, and every album until
|
||||||
|
* a rescan repopulates `audio_files.total_tracks` — this is a plain
|
||||||
|
* `in-library` and says nothing, which is the rule the badge's own
|
||||||
|
* documentation states and the reason `Known` exists at all.
|
||||||
|
*/
|
||||||
|
export function albumBadgeFor(
|
||||||
|
album: Ownable | null | undefined,
|
||||||
|
mbid?: string | null,
|
||||||
|
): BadgeState {
|
||||||
|
if (!isOwned(album)) {
|
||||||
|
return { status: libraryStatusFor(false, mbid), owned: 0, expected: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const held = completenessStore.completeness(album?.localId);
|
||||||
|
|
||||||
|
if (held?.known && !held.complete) {
|
||||||
|
return {
|
||||||
|
status: 'partial',
|
||||||
|
owned: held.owned,
|
||||||
|
expected: held.expected,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { status: 'in-library', owned: 0, expected: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
/** What a badge can ask for. Artists are deliberately absent: a
|
/** What a badge can ask for. Artists are deliberately absent: a
|
||||||
* discography subscription is `explore-artist-details`'s Follow
|
* discography subscription is `explore-artist-details`'s Follow
|
||||||
* button, which can say what it is committing to. */
|
* button, which can say what it is committing to. */
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
/**
|
||||||
|
* What "I do not own this" looks like, and how the app decides it.
|
||||||
|
*
|
||||||
|
* The rule the user asked for, in their words: *owned content is the
|
||||||
|
* default, normal, unadorned presentation; unowned content is what gets
|
||||||
|
* marked*. `explore-album-details` implemented it for one tracklist —
|
||||||
|
* dimmed in place, `aria-disabled` because dimming is a colour and
|
||||||
|
* cannot be the only signal, and nothing at all drawn on the owned rows
|
||||||
|
* — and every other catalog surface still mixed the two with a small
|
||||||
|
* badge as the only difference. This is that rule, written once, so
|
||||||
|
* eight surfaces cannot each keep their own version of it.
|
||||||
|
*
|
||||||
|
* ## Ownership is a file, and `localId` is the flag that says so
|
||||||
|
*
|
||||||
|
* The album page answers "do I own this row" with `filePaths`, a map
|
||||||
|
* from a displayed track to a real file. A card grid cannot afford a
|
||||||
|
* lookup per card — and does not need one, because the answer is
|
||||||
|
* already on every model.
|
||||||
|
*
|
||||||
|
* `explore_index.local_artist_id` / `local_release_group_id` /
|
||||||
|
* `local_recording_id` are built by `collectLibraryEntities` from
|
||||||
|
* queries that every one join `audio_files`, and cleared by
|
||||||
|
* `pruneStaleLocalCrossReferences` whose existence test is a file test
|
||||||
|
* in all three cases. That is the same "ownership is a file" rule,
|
||||||
|
* computed once per scan instead of once per screenful.
|
||||||
|
*
|
||||||
|
* **`inLibrary` is the weaker one and is deliberately not consulted.**
|
||||||
|
* It is written by the same pass, so today the two agree — but it is a
|
||||||
|
* one-way ratchet (`in_library = MAX(in_library, excluded.in_library)`)
|
||||||
|
* whose only clearing pass is gated on a non-null `local_*_id`, so it
|
||||||
|
* cannot be un-set on its own. One of the two is a fact with an owner;
|
||||||
|
* the other is a flag that happens to agree with it.
|
||||||
|
*
|
||||||
|
* The divergence was observable before this: both `explore-view` and
|
||||||
|
* `explore-artist-details` kept a `libraryMBIDs` set that accumulated
|
||||||
|
* every MBID ever seen with `inLibrary` and cleared it never, in a view
|
||||||
|
* that never unmounts. And on one artist-detail card the two answers
|
||||||
|
* were used side by side — the context menu gated Play on
|
||||||
|
* `localId > 0` while the badge said "in your library" from
|
||||||
|
* `inLibrary`, so a card could claim to be owned, offer no Play, and
|
||||||
|
* (the request item being gated on *not* owned) offer no way to ask for
|
||||||
|
* it either.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { css } from 'lit';
|
||||||
|
|
||||||
|
/** Anything a card or row can be drawn from, as far as this is concerned. */
|
||||||
|
export interface Ownable {
|
||||||
|
/** The local row id behind this entity: an album, a file, an artist. */
|
||||||
|
localId?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether there is something of the user's behind this entity.
|
||||||
|
*
|
||||||
|
* Deliberately narrow: a local id and nothing else. Passing the model
|
||||||
|
* straight in is the point — a call site that has to remember which of
|
||||||
|
* two fields to read is a call site that will eventually read the other
|
||||||
|
* one, which is exactly how the two answers came to sit on one card.
|
||||||
|
*/
|
||||||
|
export function isOwned(entity: Ownable | null | undefined): boolean {
|
||||||
|
return (entity?.localId ?? 0) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The kinds of thing a catalog surface can draw. */
|
||||||
|
export type OwnableKind = 'album' | 'track' | 'artist';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The sentence an unowned thing says, once.
|
||||||
|
*
|
||||||
|
* It reaches whoever is not seeing the dimming, so it has to name the
|
||||||
|
* thing as well as the state — "not in your library" alone, repeated
|
||||||
|
* down a grid, identifies nothing. The em dash matches the album
|
||||||
|
* tracklist's existing phrasing, which is where this came from.
|
||||||
|
*/
|
||||||
|
export function unownedLabel(name: string, kind: OwnableKind): string {
|
||||||
|
return `${name} — not in your library, ${
|
||||||
|
kind === 'artist' ? 'browsing the catalog' : 'available to request'
|
||||||
|
}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The accessible name for a card or row, owned or not.
|
||||||
|
*
|
||||||
|
* `activates` is what the thing does when it is yours: "Play", "Album",
|
||||||
|
* whatever the surface's own verb is. An unowned one does not get that
|
||||||
|
* verb, because it cannot do it.
|
||||||
|
*/
|
||||||
|
export function ownershipLabel(
|
||||||
|
owned: boolean,
|
||||||
|
activates: string,
|
||||||
|
name: string,
|
||||||
|
kind: OwnableKind,
|
||||||
|
): string {
|
||||||
|
return owned ? `${activates} ${name}` : unownedLabel(name, kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The dimming, shared so it cannot drift across surfaces.
|
||||||
|
*
|
||||||
|
* Two things about it are load-bearing.
|
||||||
|
*
|
||||||
|
* **The text dims to a token, not with `opacity`.** `theme-store`'s
|
||||||
|
* ramps are checked by `theme-contrast.test.ts` against every surface
|
||||||
|
* text can sit on; an opacity multiplier is outside that check and
|
||||||
|
* would quietly drop a dimmed title under 4.5:1 on the light ramps.
|
||||||
|
* Secondary rather than tertiary for the reason the album tracklist
|
||||||
|
* gives: these rows and cards have a `bgOverlay` hover background,
|
||||||
|
* which tertiary does not clear.
|
||||||
|
*
|
||||||
|
* **Only the artwork takes an `opacity`.** A cover is not text, so it
|
||||||
|
* is outside the contrast rule entirely, and it is the part of a card
|
||||||
|
* that carries the most weight — dimming it is what makes a grid read
|
||||||
|
* as catalog at a glance rather than needing the badge to be found.
|
||||||
|
*/
|
||||||
|
export const unownedStyles = css`
|
||||||
|
.unowned .album-title,
|
||||||
|
.unowned .track-title,
|
||||||
|
.unowned .card-name,
|
||||||
|
.unowned .top-release-title,
|
||||||
|
.unowned .artist-name {
|
||||||
|
color: var(--yj-text-secondary, #b3b3b3);
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unowned .album-art-container,
|
||||||
|
.unowned .top-release-art,
|
||||||
|
.unowned .track-art,
|
||||||
|
.unowned .card-image,
|
||||||
|
.unowned .card-image-placeholder,
|
||||||
|
.unowned .artist-avatar {
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
`;
|
||||||
@@ -0,0 +1,286 @@
|
|||||||
|
/**
|
||||||
|
* Being told about a match while looking at the album.
|
||||||
|
*
|
||||||
|
* The complaint (#28) is that the user had to notice their metadata
|
||||||
|
* was missing and then go and hunt the album down on the Autotag page.
|
||||||
|
* So the suggestion is drawn here, with something to do about it — and
|
||||||
|
* the something rewrites files on disk, which is what most of this
|
||||||
|
* file is about.
|
||||||
|
*
|
||||||
|
* The confidence tier behind "MusicBrainz has a match" is decided in
|
||||||
|
* the backend (`autotag.ConfidentTier`) so this page and strict
|
||||||
|
* auto-accept cannot disagree about what it means; what is pinned here
|
||||||
|
* is only what the page does with the answer.
|
||||||
|
*/
|
||||||
|
import { describe, expect, it, beforeEach } from 'vitest';
|
||||||
|
import type { LitElement } from 'lit';
|
||||||
|
import { page } from 'vitest/browser';
|
||||||
|
|
||||||
|
import '@components/explore-album-details/explore-album-details';
|
||||||
|
import { stub, stubFailure, flush, resetHarness, calls } from '@test/support/harness';
|
||||||
|
import { notificationStore } from '@store/notification-store';
|
||||||
|
import '@components/notifications/notification-host';
|
||||||
|
import { fixture, shadow, shadowAll } from '@test/support/render';
|
||||||
|
|
||||||
|
const MATCH = 'autotagservice.Service.MatchForAlbum';
|
||||||
|
const APPLY = 'autotagservice.Service.ApplyAsync';
|
||||||
|
|
||||||
|
function match(over: Record<string, unknown> = {}) {
|
||||||
|
return {
|
||||||
|
groupKey: 'grp-1',
|
||||||
|
recommendation: 'strong',
|
||||||
|
score: 0.95,
|
||||||
|
releaseMbid: 'rel-1',
|
||||||
|
title: 'Glass Harbour',
|
||||||
|
artistCredit: 'Tideline',
|
||||||
|
trackCount: 10,
|
||||||
|
groupCount: 1,
|
||||||
|
...over,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function albumPage(): Promise<LitElement> {
|
||||||
|
const el = await fixture<LitElement>('explore-album-details', {
|
||||||
|
albumName: 'Glass Harbour',
|
||||||
|
localAlbumId: 7,
|
||||||
|
});
|
||||||
|
|
||||||
|
await flush();
|
||||||
|
await el.updateComplete;
|
||||||
|
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The confirm dialog attaches itself to the document on first use. */
|
||||||
|
function confirmHost(): (LitElement & { shadowRoot: ShadowRoot | null }) | null {
|
||||||
|
return document.querySelector('confirm-dialog');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Press one of the dialog's own buttons, the way a person would. */
|
||||||
|
async function pressConfirm(testid: string): Promise<void> {
|
||||||
|
const host = confirmHost();
|
||||||
|
|
||||||
|
if (!host) throw new Error('confirm-dialog did not mount itself');
|
||||||
|
|
||||||
|
await host.updateComplete;
|
||||||
|
host.shadowRoot
|
||||||
|
?.querySelector<HTMLButtonElement>(`[data-testid="${testid}"]`)
|
||||||
|
?.click();
|
||||||
|
await host.updateComplete;
|
||||||
|
await flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Click one of the banner's buttons by its label. */
|
||||||
|
async function pressBanner(el: LitElement, label: string): Promise<void> {
|
||||||
|
shadowAll<HTMLElement>(el, '.autotag-match-actions wa-button')
|
||||||
|
.find((b) => b.textContent?.includes(label))
|
||||||
|
?.click();
|
||||||
|
await flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetHarness();
|
||||||
|
notificationStore.clear();
|
||||||
|
stub('explore.Service.BrowseReleases', []);
|
||||||
|
stub('explore.Service.LookupReleaseGroup', null);
|
||||||
|
stub('explore.Service.GetThumbnail', '');
|
||||||
|
stub('library.Library.GetAlbumTracks', []);
|
||||||
|
stub('library.Library.GetAlbumCompleteness', {
|
||||||
|
owned: 0,
|
||||||
|
expected: 0,
|
||||||
|
known: false,
|
||||||
|
complete: false,
|
||||||
|
});
|
||||||
|
stub('library.Library.GetAllLibrariesWithTrackCounts', []);
|
||||||
|
stub('library.Library.GetFilePathsByRecordingMBIDs', {});
|
||||||
|
stub(MATCH, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('the autotag suggestion', () => {
|
||||||
|
it('says nothing when the backend has nothing confident', async () => {
|
||||||
|
const el = await albumPage();
|
||||||
|
|
||||||
|
expect(shadow(el, '.autotag-match')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A pure catalog page has no files to retag, so the question is not
|
||||||
|
* asked at all — this runs on every album open and a call that
|
||||||
|
* cannot have an answer is a call not worth making.
|
||||||
|
*/
|
||||||
|
it('is not even asked about an album with no local files', async () => {
|
||||||
|
stub(MATCH, match());
|
||||||
|
|
||||||
|
const el = await fixture<LitElement>('explore-album-details', {
|
||||||
|
albumName: 'Glass Harbour',
|
||||||
|
releaseGroupMBID: 'rg-1',
|
||||||
|
});
|
||||||
|
|
||||||
|
await flush();
|
||||||
|
await el.updateComplete;
|
||||||
|
|
||||||
|
expect(calls(MATCH)).toHaveLength(0);
|
||||||
|
expect(shadow(el, '.autotag-match')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The banner names the release rather than quoting a number: 0.95
|
||||||
|
* reads as a probability and is not one, and which release it is, is
|
||||||
|
* the thing the user can actually judge.
|
||||||
|
*/
|
||||||
|
it('names the release it is offering', async () => {
|
||||||
|
stub(MATCH, match());
|
||||||
|
|
||||||
|
const el = await albumPage();
|
||||||
|
const text = shadow(el, '.autotag-match')?.textContent ?? '';
|
||||||
|
|
||||||
|
expect(text).toContain('MusicBrainz has a match');
|
||||||
|
expect(text).toContain('Glass Harbour');
|
||||||
|
expect(text).toContain('Tideline');
|
||||||
|
expect(text).not.toContain('95');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('offers both an apply and a review', async () => {
|
||||||
|
stub(MATCH, match());
|
||||||
|
|
||||||
|
await albumPage();
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.element(page.getByRole('button', { name: 'Apply tags' }))
|
||||||
|
.toBeInTheDocument();
|
||||||
|
await expect
|
||||||
|
.element(page.getByRole('button', { name: 'Review in Autotag' }))
|
||||||
|
.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A tagging group is a folder, so a multi-disc album is several. One
|
||||||
|
* button that applied to the best-scoring one would leave the album
|
||||||
|
* holding a mix of old and new tags — which is the case the app's
|
||||||
|
* Blocking notification level exists for, and is worth not creating.
|
||||||
|
*/
|
||||||
|
it('will not apply to an album filed as several folders', async () => {
|
||||||
|
stub(MATCH, match({ groupCount: 2 }));
|
||||||
|
|
||||||
|
const el = await albumPage();
|
||||||
|
const labels = shadowAll(el, '.autotag-match-actions wa-button').map(
|
||||||
|
(b) => b.textContent?.trim(),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(labels).toEqual(['Review in Autotag']);
|
||||||
|
expect(shadow(el, '.autotag-match')?.textContent).toContain('2 folders');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('navigates to Autotag carrying the group key', async () => {
|
||||||
|
stub(MATCH, match());
|
||||||
|
|
||||||
|
const el = await albumPage();
|
||||||
|
const seen: CustomEvent[] = [];
|
||||||
|
|
||||||
|
el.addEventListener('navigate', (e) => seen.push(e as CustomEvent));
|
||||||
|
|
||||||
|
await pressBanner(el, 'Review');
|
||||||
|
|
||||||
|
expect(seen).toHaveLength(1);
|
||||||
|
expect(seen[0]?.detail).toMatchObject({
|
||||||
|
view: 'autotag',
|
||||||
|
groupKey: 'grp-1',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('applying from the album page', () => {
|
||||||
|
/**
|
||||||
|
* This rewrites tags in files on disk and cannot be undone, so it
|
||||||
|
* asks first — and cancelling has to be a true no-op, not a
|
||||||
|
* confirmation that fires the call anyway.
|
||||||
|
*/
|
||||||
|
it('asks before it writes, and cancelling writes nothing', async () => {
|
||||||
|
stub(MATCH, match());
|
||||||
|
stub(APPLY, null);
|
||||||
|
|
||||||
|
const el = await albumPage();
|
||||||
|
|
||||||
|
await pressBanner(el, 'Apply');
|
||||||
|
|
||||||
|
expect(confirmHost()).not.toBeNull();
|
||||||
|
expect(calls(APPLY)).toHaveLength(0);
|
||||||
|
|
||||||
|
await pressConfirm('confirm-cancel');
|
||||||
|
await el.updateComplete;
|
||||||
|
|
||||||
|
expect(calls(APPLY)).toHaveLength(0);
|
||||||
|
expect(shadow(el, '.autotag-match')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The impact line has to say the thing that cannot be taken back, in
|
||||||
|
* those words — "cannot be undone" — and that nothing is deleted,
|
||||||
|
* because "rewrites your files" reads worse than it is.
|
||||||
|
*/
|
||||||
|
it('says what cannot be undone', async () => {
|
||||||
|
stub(MATCH, match());
|
||||||
|
|
||||||
|
const el = await albumPage();
|
||||||
|
|
||||||
|
await pressBanner(el, 'Apply');
|
||||||
|
|
||||||
|
const text = confirmHost()?.shadowRoot?.textContent ?? '';
|
||||||
|
|
||||||
|
expect(text).toContain('cannot be');
|
||||||
|
expect(text).toContain('undone');
|
||||||
|
expect(text.toLowerCase()).toContain('nothing is moved or deleted');
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `ApplyAsync` is the registered-job path, so progress belongs to
|
||||||
|
* the jobs indicator and this page does not grow a second one. What
|
||||||
|
* it owes the user is an acknowledgement, because the button is
|
||||||
|
* here — and the suggestion has to stop inviting a second click.
|
||||||
|
*/
|
||||||
|
it('hands the work to the job registry and stands down', async () => {
|
||||||
|
stub(MATCH, match());
|
||||||
|
stub(APPLY, null);
|
||||||
|
|
||||||
|
const el = await albumPage();
|
||||||
|
|
||||||
|
await pressBanner(el, 'Apply');
|
||||||
|
await pressConfirm('confirm-accept');
|
||||||
|
await el.updateComplete;
|
||||||
|
|
||||||
|
expect(calls(APPLY)).toHaveLength(1);
|
||||||
|
// The release is passed explicitly: a rescore between the page
|
||||||
|
// rendering and the click must not swap the album out from under
|
||||||
|
// a button the user has already read.
|
||||||
|
expect(calls(APPLY)[0]?.args).toEqual(['grp-1', 'rel-1']);
|
||||||
|
expect(shadow(el, '.autotag-match')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A failure is Persistent, not Transient: the user asked for
|
||||||
|
* something that did not happen and retrying is meaningful, which is
|
||||||
|
* the notification store's own rule for choosing the level.
|
||||||
|
*/
|
||||||
|
it('keeps a failure on screen', async () => {
|
||||||
|
stub(MATCH, match());
|
||||||
|
stubFailure(APPLY, 'the tag writer refused');
|
||||||
|
|
||||||
|
const el = await albumPage();
|
||||||
|
|
||||||
|
await pressBanner(el, 'Apply');
|
||||||
|
await pressConfirm('confirm-accept');
|
||||||
|
await el.updateComplete;
|
||||||
|
|
||||||
|
// Read it the way a person would: the app's one notification
|
||||||
|
// surface, rendered.
|
||||||
|
const host = await fixture<LitElement>('notification-host');
|
||||||
|
|
||||||
|
await host.updateComplete;
|
||||||
|
|
||||||
|
const shown = shadowAll(host, '[data-testid="notification"]').map(
|
||||||
|
(n) => n.textContent ?? '',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(shown.join(' ')).toContain('could not be');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
/**
|
||||||
|
* Choosing a pressing is a repair job, not the album page's headline.
|
||||||
|
*
|
||||||
|
* The version selector sat directly above the tracklist with a heading,
|
||||||
|
* a `<select>` and a paragraph explaining how our clustering picks a
|
||||||
|
* "standard version" — the most valuable space on the page spent on a
|
||||||
|
* control a normal user never touches (#17). Two more blocks shared
|
||||||
|
* that slot and were not even guarded by "is there a choice": a
|
||||||
|
* `Versions / Loading releases…` spinner about the same fetch
|
||||||
|
* `renderTracklist` was already reporting, and a `Versions / <error>`
|
||||||
|
* block duplicating what `catalog-scope-notice` shows at the top of the
|
||||||
|
* page with a retry.
|
||||||
|
*
|
||||||
|
* What is pinned here is the demotion and the three things that must
|
||||||
|
* survive it: the control is still reachable, the page still says which
|
||||||
|
* version you are looking at once you have chosen one, and a failed
|
||||||
|
* fetch still says so somewhere a collapsed panel is not.
|
||||||
|
*/
|
||||||
|
import { describe, expect, it, beforeEach } from 'vitest';
|
||||||
|
import type { LitElement } from 'lit';
|
||||||
|
import { page } from 'vitest/browser';
|
||||||
|
|
||||||
|
import '@components/explore-album-details/explore-album-details';
|
||||||
|
import { stub, stubFailure, flush, resetHarness } from '@test/support/harness';
|
||||||
|
import { fixture, shadow, shadowAll } from '@test/support/render';
|
||||||
|
|
||||||
|
const MBID = 'rg-0001';
|
||||||
|
|
||||||
|
function track(n: number) {
|
||||||
|
return {
|
||||||
|
position: n,
|
||||||
|
discNumber: 1,
|
||||||
|
title: `Track ${n}`,
|
||||||
|
length: 200000,
|
||||||
|
mbid: `rec-${n}`,
|
||||||
|
inLibrary: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function release(mbid: string, date: string, trackCount: number) {
|
||||||
|
return {
|
||||||
|
mbid,
|
||||||
|
title: 'Glass Harbour',
|
||||||
|
date,
|
||||||
|
status: 'Official',
|
||||||
|
tracks: Array.from({ length: trackCount }, (_, i) => track(i + 1)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const UNKNOWN = { owned: 0, expected: 0, known: false, complete: false };
|
||||||
|
|
||||||
|
/** Two releases whose tracklists genuinely differ, so there is a choice. */
|
||||||
|
const TWO = [release('rel-1', '2019-04-01', 10), release('rel-2', '2020-09-01', 14)];
|
||||||
|
|
||||||
|
async function album(releases: unknown[] = TWO): Promise<LitElement> {
|
||||||
|
stub('explore.Service.BrowseReleases', releases);
|
||||||
|
stub('library.Library.GetAlbumCompleteness', UNKNOWN);
|
||||||
|
stub('library.Library.GetAlbumTracks', []);
|
||||||
|
|
||||||
|
const el = await fixture<LitElement>('explore-album-details', {
|
||||||
|
releaseGroupMBID: MBID,
|
||||||
|
localAlbumId: 7,
|
||||||
|
albumName: 'Glass Harbour',
|
||||||
|
});
|
||||||
|
|
||||||
|
await flush();
|
||||||
|
await el.updateComplete;
|
||||||
|
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Positions of two selectors within the shadow root, in document order. */
|
||||||
|
function order(el: Element, first: string, second: string): [number, number] {
|
||||||
|
const all = [...(el.shadowRoot?.querySelectorAll('*') ?? [])];
|
||||||
|
const a = all.findIndex((n) => n.matches(first));
|
||||||
|
const b = all.findIndex((n) => n.matches(second));
|
||||||
|
|
||||||
|
return [a, b];
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetHarness();
|
||||||
|
stub('explore.Service.LookupReleaseGroup', {
|
||||||
|
mbid: MBID,
|
||||||
|
title: 'Glass Harbour',
|
||||||
|
artistCredit: 'Tideline',
|
||||||
|
});
|
||||||
|
stub('explore.Service.GetThumbnail', '');
|
||||||
|
stub('library.Library.GetAllLibrariesWithTrackCounts', []);
|
||||||
|
stub('library.Library.GetFilePathsByRecordingMBIDs', {});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('the version selector is no longer the headline', () => {
|
||||||
|
it('renders after the tracklist, not before it', async () => {
|
||||||
|
const el = await album();
|
||||||
|
const [tracklist, versions] = order(el, '.tracklist', '.versions');
|
||||||
|
|
||||||
|
expect(tracklist).toBeGreaterThan(-1);
|
||||||
|
expect(versions).toBeGreaterThan(tracklist);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts collapsed', async () => {
|
||||||
|
const el = await album();
|
||||||
|
|
||||||
|
expect(shadow(el, '#versions-body')?.hasAttribute('hidden')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `aria-controls` has to name an element that is in the DOM, so the
|
||||||
|
* body renders unconditionally and is toggled with `hidden` — the
|
||||||
|
* rule `config-section` states and the reason a conditional body
|
||||||
|
* would be wrong here too.
|
||||||
|
*/
|
||||||
|
it('keeps the panel in the DOM while it is shut', async () => {
|
||||||
|
const el = await album();
|
||||||
|
|
||||||
|
expect(shadow(el, '#versions-body')).not.toBeNull();
|
||||||
|
expect(
|
||||||
|
shadow(el, '.versions-toggle')?.getAttribute('aria-controls'),
|
||||||
|
).toBe('versions-body');
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The browser's own answer: a disclosure that cannot be tabbed to is
|
||||||
|
* the fault `config-section` shipped for every setting in the app,
|
||||||
|
* and a shadow-root query cannot tell you a control has a name.
|
||||||
|
*/
|
||||||
|
it('is a named, expandable button', async () => {
|
||||||
|
await album();
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.element(
|
||||||
|
page.getByRole('button', { name: /Other versions of this album \(2\)/ }),
|
||||||
|
)
|
||||||
|
.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens when the button is pressed', async () => {
|
||||||
|
const el = await album();
|
||||||
|
const toggle = shadow<HTMLButtonElement>(el, '.versions-toggle');
|
||||||
|
|
||||||
|
expect(toggle?.getAttribute('aria-expanded')).toBe('false');
|
||||||
|
|
||||||
|
toggle?.click();
|
||||||
|
await el.updateComplete;
|
||||||
|
|
||||||
|
expect(toggle?.getAttribute('aria-expanded')).toBe('true');
|
||||||
|
expect(shadow(el, '#versions-body')?.hasAttribute('hidden')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('says nothing at all when there is only one tracklist', async () => {
|
||||||
|
const el = await album([release('rel-1', '2019-04-01', 10)]);
|
||||||
|
|
||||||
|
expect(shadow(el, '.versions')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('the two blocks that shared that slot', () => {
|
||||||
|
/**
|
||||||
|
* The spinner was unguarded, so `Versions / Loading releases…` took
|
||||||
|
* the primary position on *every* album load — including the ones
|
||||||
|
* that would never offer a choice — beside `renderTracklist`'s own
|
||||||
|
* "Loading tracks…" about the same fetch.
|
||||||
|
*/
|
||||||
|
it('no longer reports the same fetch twice while loading', async () => {
|
||||||
|
stub('library.Library.GetAlbumCompleteness', UNKNOWN);
|
||||||
|
stub('library.Library.GetAlbumTracks', []);
|
||||||
|
stub('explore.Service.BrowseReleases', () => new Promise(() => {}));
|
||||||
|
|
||||||
|
const el = await fixture<LitElement>('explore-album-details', {
|
||||||
|
releaseGroupMBID: MBID,
|
||||||
|
albumName: 'Glass Harbour',
|
||||||
|
});
|
||||||
|
|
||||||
|
const loading = shadowAll(el, '.section-loading');
|
||||||
|
|
||||||
|
expect(loading).toHaveLength(1);
|
||||||
|
expect(loading[0]?.textContent).toContain('Loading tracks');
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A failed browse must still be visible, and it cannot be visible
|
||||||
|
* from inside a collapsed disclosure. It belongs to the list that is
|
||||||
|
* missing because of it — `renderTracklist` used to return `nothing`
|
||||||
|
* here and lean on the selector's own error block, which is exactly
|
||||||
|
* the coupling that made this a rewrite rather than a move.
|
||||||
|
*/
|
||||||
|
it('reports a failed fetch in the tracklist, once', async () => {
|
||||||
|
stub('library.Library.GetAlbumCompleteness', UNKNOWN);
|
||||||
|
stub('library.Library.GetAlbumTracks', []);
|
||||||
|
stubFailure('explore.Service.BrowseReleases', 'the catalog said no');
|
||||||
|
|
||||||
|
const el = await fixture<LitElement>('explore-album-details', {
|
||||||
|
releaseGroupMBID: MBID,
|
||||||
|
albumName: 'Glass Harbour',
|
||||||
|
});
|
||||||
|
|
||||||
|
await flush();
|
||||||
|
await el.updateComplete;
|
||||||
|
|
||||||
|
const errors = shadowAll(el, '.section-error');
|
||||||
|
|
||||||
|
expect(errors).toHaveLength(1);
|
||||||
|
expect(errors[0]?.textContent).toContain('versions');
|
||||||
|
expect(shadow(el, '.versions')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('which version is on screen', () => {
|
||||||
|
/**
|
||||||
|
* The default is what the header already describes, so a line saying
|
||||||
|
* so on every album would be the thing this issue removed, one size
|
||||||
|
* smaller.
|
||||||
|
*/
|
||||||
|
it('is not stated while the page picked it', async () => {
|
||||||
|
const el = await album();
|
||||||
|
|
||||||
|
expect(shadow(el, '.chosen-version')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The moment someone chooses another, the tracklist and the header
|
||||||
|
* disagree — and the control that explains it is now off the bottom
|
||||||
|
* of the page.
|
||||||
|
*/
|
||||||
|
it('is stated above the tracklist once the user chooses', async () => {
|
||||||
|
const el = await album();
|
||||||
|
const select = shadow<HTMLSelectElement>(el, '#version-select')!;
|
||||||
|
const other = [...select.options].find((o) => o.value !== select.value)!;
|
||||||
|
|
||||||
|
select.value = other.value;
|
||||||
|
select.dispatchEvent(new Event('change'));
|
||||||
|
await el.updateComplete;
|
||||||
|
|
||||||
|
const line = shadow(el, '.chosen-version');
|
||||||
|
|
||||||
|
expect(line).not.toBeNull();
|
||||||
|
|
||||||
|
const [chosen, tracklist] = order(el, '.chosen-version', '.tracklist');
|
||||||
|
|
||||||
|
expect(chosen).toBeGreaterThan(-1);
|
||||||
|
expect(tracklist).toBeGreaterThan(chosen);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('offers a way back, which clears the line', async () => {
|
||||||
|
const el = await album();
|
||||||
|
const select = shadow<HTMLSelectElement>(el, '#version-select')!;
|
||||||
|
const first = select.value;
|
||||||
|
const other = [...select.options].find((o) => o.value !== first)!;
|
||||||
|
|
||||||
|
select.value = other.value;
|
||||||
|
select.dispatchEvent(new Event('change'));
|
||||||
|
await el.updateComplete;
|
||||||
|
|
||||||
|
shadow<HTMLButtonElement>(el, '.chosen-version-reset')?.click();
|
||||||
|
await el.updateComplete;
|
||||||
|
|
||||||
|
expect(shadow<HTMLSelectElement>(el, '#version-select')?.value).toBe(first);
|
||||||
|
expect(shadow(el, '.chosen-version')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
/** A panel that shuts on use cannot be used twice. */
|
||||||
|
it('leaves the disclosure open after a choice', async () => {
|
||||||
|
const el = await album();
|
||||||
|
|
||||||
|
shadow<HTMLButtonElement>(el, '.versions-toggle')?.click();
|
||||||
|
await el.updateComplete;
|
||||||
|
|
||||||
|
const select = shadow<HTMLSelectElement>(el, '#version-select')!;
|
||||||
|
const other = [...select.options].find((o) => o.value !== select.value)!;
|
||||||
|
|
||||||
|
select.value = other.value;
|
||||||
|
select.dispatchEvent(new Event('change'));
|
||||||
|
await el.updateComplete;
|
||||||
|
|
||||||
|
expect(shadow(el, '#versions-body')?.hasAttribute('hidden')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
/**
|
||||||
|
* "Review in Autotag" has to land on *that* album.
|
||||||
|
*
|
||||||
|
* The album page can now say the autotagger has a match for what you
|
||||||
|
* are looking at (#28), and the review link is only worth having if it
|
||||||
|
* opens the same album. The queue is sorted by score, so the intended
|
||||||
|
* folder is often near the top — but "often" is a link that sometimes
|
||||||
|
* opens a different album, which is worse than no link.
|
||||||
|
*
|
||||||
|
* Autotag is a **cached primary view**: `index.ts` creates it once and
|
||||||
|
* reuses it, so there is no construction to pass a value to. The
|
||||||
|
* request arrives as an attribute, which is why the interesting part
|
||||||
|
* is that it is *consumed* — an attribute left on a cached element
|
||||||
|
* would reopen the same folder on every later visit to the page.
|
||||||
|
*/
|
||||||
|
import { describe, expect, it, beforeEach } from 'vitest';
|
||||||
|
import type { LitElement } from 'lit';
|
||||||
|
|
||||||
|
import '@components/autotag-view/autotag-view';
|
||||||
|
import { flush, resetHarness, stub } from '@test/support/harness';
|
||||||
|
import { fixture } from '@test/support/render';
|
||||||
|
|
||||||
|
const FOLDERS = 'autotagservice.Service.ListPendingFolders';
|
||||||
|
const CANDIDATES = 'autotagservice.Service.GetCandidates';
|
||||||
|
|
||||||
|
function folder(groupKey: string, album: string) {
|
||||||
|
return {
|
||||||
|
groupKey,
|
||||||
|
libraryId: 0,
|
||||||
|
libraryName: 'Test',
|
||||||
|
folderSubPath: album,
|
||||||
|
trackCount: 10,
|
||||||
|
albumName: album,
|
||||||
|
albumArtist: 'Tideline',
|
||||||
|
discNumber: 0,
|
||||||
|
status: 'pending',
|
||||||
|
score: groupKey === 'grp-top' ? 0.99 : 0.5,
|
||||||
|
bestMatchReleaseMbid: 'rel-1',
|
||||||
|
synthetic: false,
|
||||||
|
likelyMixedBag: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mount the view and run its activation, as navigation would. */
|
||||||
|
async function autotag(groupKey?: string): Promise<LitElement> {
|
||||||
|
const el = await fixture<LitElement>('autotag-view');
|
||||||
|
|
||||||
|
if (groupKey !== undefined) el.setAttribute('group-key', groupKey);
|
||||||
|
|
||||||
|
(el as unknown as { onViewActivate: () => void }).onViewActivate();
|
||||||
|
|
||||||
|
await flush();
|
||||||
|
await el.updateComplete;
|
||||||
|
await flush();
|
||||||
|
await el.updateComplete;
|
||||||
|
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The folder the view has selected. */
|
||||||
|
function selected(el: LitElement): string | undefined {
|
||||||
|
return (el as unknown as { current?: { groupKey: string } }).current
|
||||||
|
?.groupKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetHarness();
|
||||||
|
stub('autotagservice.Service.StartAutotagQueue', null);
|
||||||
|
stub('autotagservice.Service.GetLocalCoverArt', '');
|
||||||
|
stub('autotagservice.Service.AckLibraryWarning', null);
|
||||||
|
stub('library.Library.GetAllLibrariesWithTrackCounts', []);
|
||||||
|
stub(FOLDERS, [folder('grp-top', 'Loudest Match'), folder('grp-asked', 'Glass Harbour')]);
|
||||||
|
stub(CANDIDATES, {
|
||||||
|
groupKey: 'grp-asked',
|
||||||
|
recommendation: 'strong',
|
||||||
|
localTracks: [],
|
||||||
|
candidates: [],
|
||||||
|
synthetic: false,
|
||||||
|
mixedBag: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('arriving at Autotag from an album page', () => {
|
||||||
|
it('opens the folder that was asked for, not the top of the queue', async () => {
|
||||||
|
const el = await autotag('grp-asked');
|
||||||
|
|
||||||
|
expect(selected(el)).toBe('grp-asked');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still lands on the best pending folder when nothing was asked', async () => {
|
||||||
|
const el = await autotag();
|
||||||
|
|
||||||
|
expect(selected(el)).toBe('grp-top');
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The view is cached and never unmounts, so an attribute left behind
|
||||||
|
* is a standing instruction: every later visit to Autotag would
|
||||||
|
* reopen an album the user finished with three navigations ago.
|
||||||
|
*/
|
||||||
|
it('consumes the request rather than remembering it', async () => {
|
||||||
|
const el = await autotag('grp-asked');
|
||||||
|
|
||||||
|
expect(el.hasAttribute('group-key')).toBe(false);
|
||||||
|
|
||||||
|
// A second visit, with no new request: whatever the user had
|
||||||
|
// selected stays selected.
|
||||||
|
(el as unknown as { onViewActivate: () => void }).onViewActivate();
|
||||||
|
await flush();
|
||||||
|
await el.updateComplete;
|
||||||
|
|
||||||
|
expect(selected(el)).toBe('grp-asked');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -19,6 +19,11 @@ import {
|
|||||||
update,
|
update,
|
||||||
visual,
|
visual,
|
||||||
} from '@test/support/render';
|
} from '@test/support/render';
|
||||||
|
import {
|
||||||
|
ICON_CAN_REQUEST,
|
||||||
|
ICON_IN_LIBRARY,
|
||||||
|
ICON_REQUESTED,
|
||||||
|
} from '@utils/icon-language';
|
||||||
|
|
||||||
describe('<app-sidebar>', () => {
|
describe('<app-sidebar>', () => {
|
||||||
it('renders a testid per destination, which is how e2e navigates', async () => {
|
it('renders a testid per destination, which is how e2e navigates', async () => {
|
||||||
@@ -154,9 +159,20 @@ describe('<library-status-indicator>', () => {
|
|||||||
it('defaults to "not in library"', async () => {
|
it('defaults to "not in library"', async () => {
|
||||||
const el = await fixture('library-status-indicator');
|
const el = await fixture('library-status-indicator');
|
||||||
|
|
||||||
expect(shadow(el, 'wa-icon')?.getAttribute('name')).toBe('plus');
|
expect(shadow(el, 'wa-icon')?.getAttribute('name')).toBe(ICON_CAN_REQUEST);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Named from the vocabulary rather than written out, or this test
|
||||||
|
* pins the glyphs *against* the table it is supposed to follow —
|
||||||
|
* which is what it did: it asserted `plus` for the un-owned state,
|
||||||
|
* the same glyph two adjacent menu items were using for two other
|
||||||
|
* meanings, and passing was the reason nobody looked.
|
||||||
|
*
|
||||||
|
* What is still worth asserting is that the three differ, which is
|
||||||
|
* the property the states need and the one the table cannot state
|
||||||
|
* about itself here.
|
||||||
|
*/
|
||||||
it('uses a distinct glyph per state', async () => {
|
it('uses a distinct glyph per state', async () => {
|
||||||
const glyphs: (string | null | undefined)[] = [];
|
const glyphs: (string | null | undefined)[] = [];
|
||||||
|
|
||||||
@@ -166,7 +182,8 @@ describe('<library-status-indicator>', () => {
|
|||||||
glyphs.push(shadow(el, 'wa-icon')?.getAttribute('name'));
|
glyphs.push(shadow(el, 'wa-icon')?.getAttribute('name'));
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(glyphs).toEqual(['check', 'bookmark', 'plus']);
|
expect(glyphs).toEqual([ICON_IN_LIBRARY, ICON_REQUESTED, ICON_CAN_REQUEST]);
|
||||||
|
expect(new Set(glyphs).size).toBe(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('phrases its label around the entity it describes', async () => {
|
it('phrases its label around the entity it describes', async () => {
|
||||||
|
|||||||
@@ -80,3 +80,42 @@ describe('confirmAction', () => {
|
|||||||
await expect(Promise.all([first, second])).resolves.toEqual([false, true]);
|
await expect(Promise.all([first, second])).resolves.toEqual([false, true]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A late `wa-hide` must not answer the next question.
|
||||||
|
*
|
||||||
|
* This is one singleton for every confirmation in the app, and
|
||||||
|
* `wa-dialog` reports its close *asynchronously* — `open = false`
|
||||||
|
* starts an animation and `wa-hide` arrives after it. So a hide
|
||||||
|
* belonging to a question already answered can land after the next one
|
||||||
|
* has opened: the user is asked something, the dialog vanishes on its
|
||||||
|
* own, and the call site is told they said no.
|
||||||
|
*
|
||||||
|
* Found by writing two `confirmAction()` tests in one file — the
|
||||||
|
* second could not be accepted at all, because the first one's hide
|
||||||
|
* had cancelled it before the click landed. In the app it needs two
|
||||||
|
* confirmations close together, which "apply these tags" now makes
|
||||||
|
* reachable.
|
||||||
|
*/
|
||||||
|
describe('two questions in a row', () => {
|
||||||
|
it('does not let the first one answer the second', async () => {
|
||||||
|
const first = confirmAction({ title: 'First?', message: 'One.' });
|
||||||
|
|
||||||
|
await press('confirm-cancel');
|
||||||
|
await expect(first).resolves.toBe(false);
|
||||||
|
|
||||||
|
const second = confirmAction({ title: 'Second?', message: 'Two.' });
|
||||||
|
|
||||||
|
// Whatever the first dialog's hide animation is still doing, the
|
||||||
|
// second question is on screen and unanswered.
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
|
||||||
|
// The title is a `label` on `wa-dialog` and lands in *its* shadow
|
||||||
|
// root; the message is the part this component renders.
|
||||||
|
expect(host().shadowRoot?.textContent ?? '').toContain('Two.');
|
||||||
|
|
||||||
|
await press('confirm-accept');
|
||||||
|
|
||||||
|
await expect(second).resolves.toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
/**
|
||||||
|
* A hover affordance is gated on the device having hover.
|
||||||
|
*
|
||||||
|
* The home page's cover cards reveal a play button on :hover. A touch
|
||||||
|
* long-press synthesises a hover state in the WebView, so on a phone
|
||||||
|
* that button flashed into view during the 500ms hold that
|
||||||
|
* utils/long-press.ts is measuring for a context menu — a control
|
||||||
|
* appearing because the user was reaching for a different one.
|
||||||
|
*
|
||||||
|
* This is asserted against the *parsed stylesheet* rather than by
|
||||||
|
* emulating a touch device, and that is a limitation worth stating
|
||||||
|
* rather than hiding. CDP's Emulation.setEmulatedMedia does not reach
|
||||||
|
* this tier's iframe — matchMedia still answers `hover: hover` after it
|
||||||
|
* is set — so there is no way here to render the component as a phone
|
||||||
|
* would and read the computed style. What can be checked is the shape
|
||||||
|
* the browser actually built from the css`` literal: that the reveal
|
||||||
|
* lives inside a hover media query and that the default is display:none.
|
||||||
|
*
|
||||||
|
* Which is the regression worth catching anyway. The failure mode is
|
||||||
|
* someone hoisting the rule back out of the query for a one-line tidy —
|
||||||
|
* a change nothing renders differently on a desktop, so every other
|
||||||
|
* assertion in this repo passes and the phone silently regresses.
|
||||||
|
*/
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import '@components/home-view/home-view';
|
||||||
|
import { fixture } from '@test/support/render';
|
||||||
|
|
||||||
|
/** Every rule in the element's own adopted stylesheets, flattened. */
|
||||||
|
function rulesOf(host: Element): { text: string; condition: string | null }[] {
|
||||||
|
const sheets = host.shadowRoot?.adoptedStyleSheets ?? [];
|
||||||
|
const out: { text: string; condition: string | null }[] = [];
|
||||||
|
|
||||||
|
for (const sheet of sheets) {
|
||||||
|
for (const rule of Array.from(sheet.cssRules)) {
|
||||||
|
if (rule instanceof CSSMediaRule) {
|
||||||
|
for (const inner of Array.from(rule.cssRules)) {
|
||||||
|
out.push({ text: inner.cssText, condition: rule.conditionText });
|
||||||
|
}
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
out.push({ text: rule.cssText, condition: null });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('the home card play button', () => {
|
||||||
|
it('reveals itself only where the device has hover', async () => {
|
||||||
|
const el = await fixture('home-view', {});
|
||||||
|
const rules = rulesOf(el);
|
||||||
|
|
||||||
|
// The sweep is worth nothing if it read no rules at all — the same
|
||||||
|
// first assertion icon-language.test.ts makes for the same reason.
|
||||||
|
expect(rules.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const reveals = rules.filter(
|
||||||
|
(r) => r.text.includes('.play') && /opacity:\s*1/.test(r.text),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(reveals.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
for (const rule of reveals) {
|
||||||
|
expect(rule.condition).toMatch(/hover:\s*hover/);
|
||||||
|
expect(rule.condition).toMatch(/pointer:\s*fine/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is display:none rather than transparent where it is absent', async () => {
|
||||||
|
const el = await fixture('home-view', {});
|
||||||
|
|
||||||
|
// opacity:0 alone would leave a button that still takes taps and is
|
||||||
|
// still in the accessibility tree, so a phone would keep the hit
|
||||||
|
// area for a control it can never see.
|
||||||
|
const unconditional = rulesOf(el).filter(
|
||||||
|
(r) => r.condition === null && r.text.startsWith('.play'),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(unconditional.length).toBeGreaterThan(0);
|
||||||
|
expect(unconditional.some((r) => /display:\s*none/.test(r.text))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
/**
|
||||||
|
* The icon vocabulary is one table, and nothing writes around it.
|
||||||
|
*
|
||||||
|
* A wrong-but-real icon name renders perfectly: no error, no fallback,
|
||||||
|
* no failing assertion anywhere. That is how `plus` came to mean "add
|
||||||
|
* to the queue", "add to a playlist", "make a new playlist" and "you do
|
||||||
|
* not own this" — the first two adjacent in the same context menu —
|
||||||
|
* while `list` meant the queue, the Playlists destination *and* adding
|
||||||
|
* to the queue.
|
||||||
|
*
|
||||||
|
* `src/icons/index.ts` catches a name that is not *bundled*. Nothing
|
||||||
|
* catches a name that is bundled and means something else, so this
|
||||||
|
* sweeps the source for the governed ones. It is the same shape as
|
||||||
|
* `TestNoDirectRuntimeEmits` and `TestNoWritesOnTheReadPool` in the
|
||||||
|
* backend, and exists for the same reason: the rule is about every call
|
||||||
|
* site, so checking one is checking nothing.
|
||||||
|
*/
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { bundledIconNames } from '../../src/icons';
|
||||||
|
import * as icons from '@utils/icon-language';
|
||||||
|
|
||||||
|
/** Every component source, as text. */
|
||||||
|
const SOURCES = import.meta.glob<string>('../../src/**/*.ts', {
|
||||||
|
eager: true,
|
||||||
|
query: '?raw',
|
||||||
|
import: 'default',
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The names that carry a meaning the table owns.
|
||||||
|
*
|
||||||
|
* Deliberately not every bundled name. `check` is `ICON_IN_LIBRARY`
|
||||||
|
* here and also the "Copied" confirmation in `job-log-view`, which is
|
||||||
|
* a different, perfectly good meaning — governing it would force a
|
||||||
|
* false rename. What belongs on this list is a name that was actually
|
||||||
|
* overloaded.
|
||||||
|
*/
|
||||||
|
const GOVERNED = [
|
||||||
|
'plus',
|
||||||
|
'list',
|
||||||
|
'bookmark',
|
||||||
|
'solid/bookmark',
|
||||||
|
'regular/bookmark',
|
||||||
|
'bars-staggered',
|
||||||
|
'tag',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** The one file allowed to say them, plus its own test. */
|
||||||
|
const DEFINITION = /icon-language\.(ts|test\.ts)$/;
|
||||||
|
|
||||||
|
describe('the icon vocabulary', () => {
|
||||||
|
/**
|
||||||
|
* A sweep over nothing passes. This is the assertion that makes the
|
||||||
|
* rest of the file mean something, and it is the first thing that
|
||||||
|
* breaks if the glob pattern stops matching after a move.
|
||||||
|
*/
|
||||||
|
it('actually reads the source', () => {
|
||||||
|
const paths = Object.keys(SOURCES);
|
||||||
|
|
||||||
|
expect(paths.length).toBeGreaterThan(100);
|
||||||
|
expect(paths.some((p) => p.endsWith('/track-list.ts'))).toBe(true);
|
||||||
|
expect(SOURCES[paths[0]!]).toContain('import');
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(GOVERNED)('is not written around for %s', (name) => {
|
||||||
|
const offenders: string[] = [];
|
||||||
|
|
||||||
|
for (const [path, source] of Object.entries(SOURCES)) {
|
||||||
|
if (DEFINITION.test(path)) continue;
|
||||||
|
|
||||||
|
// Both spellings: an icon in a template, and an icon name in a
|
||||||
|
// data table (which is how the sidebar and bottom-nav carry
|
||||||
|
// theirs).
|
||||||
|
const literal = new RegExp(
|
||||||
|
`(name="${name}"|icon: '${name}'|name=\\$\\{[^}]*'${name}')`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (literal.test(source)) offenders.push(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(offenders).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A meaning with no icon behind it is the state the badge's `queued`
|
||||||
|
* spent a year in — declared, styled, and produced by nothing.
|
||||||
|
*/
|
||||||
|
it('gives every meaning a name', () => {
|
||||||
|
const values = Object.entries(icons).filter(([k]) => k.startsWith('ICON_'));
|
||||||
|
|
||||||
|
expect(values.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
for (const [key, value] of values) {
|
||||||
|
expect(`${key}=${value}`).toMatch(/^ICON_[A-Z_]+=[a-z]+[a-z/-]*$/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every name in the table is a name the app actually ships.
|
||||||
|
*
|
||||||
|
* This is the loop the vocabulary closes. A name that is not bundled
|
||||||
|
* renders a circled question mark and reports itself to
|
||||||
|
* `__yjIconMisses` — at *runtime*, from a state something has to
|
||||||
|
* reach first. `bookmark-check` is Font Awesome **Pro**, and it was
|
||||||
|
* on `explore-artist-details`'s Follow button, drawn for every
|
||||||
|
* followed artist, invisible to `offline-icons.spec.ts` because no
|
||||||
|
* spec had ever followed one. Reaching the state is no longer how
|
||||||
|
* this is found.
|
||||||
|
*/
|
||||||
|
it('names only icons that are bundled', () => {
|
||||||
|
const bundled = new Set(bundledIconNames());
|
||||||
|
const missing = Object.entries(icons)
|
||||||
|
.filter(([k]) => k.startsWith('ICON_'))
|
||||||
|
.filter(([, v]) => !bundled.has(v as string))
|
||||||
|
.map(([k, v]) => `${k} (${v})`);
|
||||||
|
|
||||||
|
expect(missing).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The two states of the request toggle have to be the same glyph in
|
||||||
|
* two weights, or they do not read as each other's opposite — which
|
||||||
|
* is what a plus against a bookmark was.
|
||||||
|
*/
|
||||||
|
it('makes the request toggle an outline/solid pair', () => {
|
||||||
|
expect(icons.ICON_CAN_REQUEST).toBe(`regular/${icons.ICON_REQUESTED.replace('solid/', '')}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The queue and the Playlists destination wore the same icon, and
|
||||||
|
* "add to queue" and "add to playlist" sat next to each other wearing
|
||||||
|
* a third same one. Whatever the table says, these three have to
|
||||||
|
* differ from each other.
|
||||||
|
*/
|
||||||
|
it('keeps the queue, playlists and creating something apart', () => {
|
||||||
|
const three = [icons.ICON_QUEUE, icons.ICON_PLAYLIST, icons.ICON_NEW];
|
||||||
|
|
||||||
|
expect(new Set(three).size).toBe(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -77,3 +77,30 @@ describe('the library status badge', () => {
|
|||||||
expect(name).toContain('Glass Harbour');
|
expect(name).toContain('Glass Harbour');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A partly-held album is the one state that is *actionable and
|
||||||
|
* counted*: there are tracks left to ask for, so the badge is a button
|
||||||
|
* — and a control is named after what activating it does, which is how
|
||||||
|
* the count came to be dropped from exactly the state the ring exists
|
||||||
|
* for. Both, or the ring says "some" to an eye and nothing to anyone
|
||||||
|
* else.
|
||||||
|
*/
|
||||||
|
describe('a partial badge that can act', () => {
|
||||||
|
it('names the action and keeps the count', async () => {
|
||||||
|
const el = await fixture('library-status-indicator', {
|
||||||
|
status: 'partial',
|
||||||
|
owned: 9,
|
||||||
|
expected: 12,
|
||||||
|
entityType: 'album',
|
||||||
|
label: 'Glass Harbour',
|
||||||
|
requestMbid: 'rg-1',
|
||||||
|
});
|
||||||
|
|
||||||
|
const name = shadow(el, '.badge')?.getAttribute('aria-label') ?? '';
|
||||||
|
|
||||||
|
expect(shadow(el, 'button.badge')).not.toBeNull();
|
||||||
|
expect(name).toContain('Request the rest of');
|
||||||
|
expect(name).toContain('9 of 12');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
/**
|
||||||
|
* #24 — the queue stops being a column when it cannot afford to be one.
|
||||||
|
*
|
||||||
|
* In flow the panel is `flex-shrink: 0`, so it takes its width *from
|
||||||
|
* the main panel* rather than covering it. Measured against the running
|
||||||
|
* app on the Playlists page, that left 379px of content at 900×600 —
|
||||||
|
* with all three of the page header's actions clipped — 69px at 390px
|
||||||
|
* wide, and **0px** at 320px, where the content was not degraded but
|
||||||
|
* gone.
|
||||||
|
*
|
||||||
|
* The rule is `available - panelWidth >= MAIN_PANEL_FLOOR`, and the
|
||||||
|
* reason it is a computed property rather than a `@media` block is the
|
||||||
|
* third test here: the panel's width is user state, drag-resizable
|
||||||
|
* between 200 and 500px and persisted, so a breakpoint on the viewport
|
||||||
|
* alone is wrong by up to 180px for a user who has widened it — in the
|
||||||
|
* direction that hurts, since a wider queue is exactly when the content
|
||||||
|
* can least afford it.
|
||||||
|
*
|
||||||
|
* The parent is `.content-area`, i.e. the viewport minus the sidebar,
|
||||||
|
* which is why these mount into a sized wrapper rather than into
|
||||||
|
* `document.body`: the width that decides this is the *parent's*, and
|
||||||
|
* `fixture()` would hand the panel the whole test window.
|
||||||
|
*/
|
||||||
|
import { describe, expect, it, afterEach } from 'vitest';
|
||||||
|
|
||||||
|
import '@components/queue-panel/queue-panel';
|
||||||
|
import type { QueuePanel } from '@components/queue-panel/queue-panel';
|
||||||
|
import { shadow } from '@test/support/render';
|
||||||
|
|
||||||
|
const wrappers: HTMLElement[] = [];
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const w of wrappers.splice(0)) w.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mount a panel inside a parent of a stated width.
|
||||||
|
*
|
||||||
|
* The wrapper is `position: relative` and `display: flex` because that
|
||||||
|
* is what `.content-area` is; the mode is measured from
|
||||||
|
* `parentElement.clientWidth`, so a wrapper that collapses to its
|
||||||
|
* content would measure the panel rather than the space around it.
|
||||||
|
*/
|
||||||
|
async function panelIn(parentWidth: number): Promise<QueuePanel> {
|
||||||
|
const wrapper = document.createElement('div');
|
||||||
|
|
||||||
|
wrapper.style.cssText = `position: relative; display: flex; width: ${parentWidth}px;`;
|
||||||
|
document.body.append(wrapper);
|
||||||
|
wrappers.push(wrapper);
|
||||||
|
|
||||||
|
const el = document.createElement('queue-panel') as QueuePanel;
|
||||||
|
|
||||||
|
el.open = true;
|
||||||
|
wrapper.append(el);
|
||||||
|
|
||||||
|
await el.updateComplete;
|
||||||
|
await settle(el);
|
||||||
|
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A ResizeObserver delivers on a frame, not a microtask, so the mode
|
||||||
|
* lands a frame after the width that decides it.
|
||||||
|
*/
|
||||||
|
async function settle(el: QueuePanel): Promise<void> {
|
||||||
|
for (let frame = 0; frame < 4; frame += 1) {
|
||||||
|
await new Promise((r) => {
|
||||||
|
requestAnimationFrame(() => r(null));
|
||||||
|
});
|
||||||
|
await el.updateComplete;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drag the resize handle by `dx`, the way a user widens the panel. */
|
||||||
|
async function dragHandleBy(el: QueuePanel, dx: number): Promise<void> {
|
||||||
|
const handle = shadow<HTMLElement>(el, '.resize-handle');
|
||||||
|
const startX = el.getBoundingClientRect().left;
|
||||||
|
|
||||||
|
if (!handle) throw new Error('no resize handle to drag');
|
||||||
|
|
||||||
|
handle.dispatchEvent(
|
||||||
|
new MouseEvent('mousedown', { clientX: startX, bubbles: true }),
|
||||||
|
);
|
||||||
|
document.dispatchEvent(
|
||||||
|
new MouseEvent('mousemove', { clientX: startX - dx, bubbles: true }),
|
||||||
|
);
|
||||||
|
document.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
|
||||||
|
|
||||||
|
await settle(el);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('the queue panel decides whether it can be a column', () => {
|
||||||
|
it('stays inline while the content can spare the width', async () => {
|
||||||
|
const el = await panelIn(1080);
|
||||||
|
|
||||||
|
expect(el.overlay).toBe(false);
|
||||||
|
expect(el.hasAttribute('overlay')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('becomes an overlay when it cannot', async () => {
|
||||||
|
const el = await panelIn(700);
|
||||||
|
|
||||||
|
expect(el.overlay).toBe(true);
|
||||||
|
expect(el.hasAttribute('overlay')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The test the media query could not have passed. The parent does not
|
||||||
|
* move; only the user's own panel width does.
|
||||||
|
*/
|
||||||
|
it('flips to overlay when the user widens the panel, at a fixed width', async () => {
|
||||||
|
const el = await panelIn(880);
|
||||||
|
|
||||||
|
expect(el.overlay).toBe(false);
|
||||||
|
|
||||||
|
await dragHandleBy(el, 180);
|
||||||
|
|
||||||
|
expect(el.overlay).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives an overlay a scrim and a named way out, and an inline panel neither', async () => {
|
||||||
|
const overlaid = await panelIn(700);
|
||||||
|
|
||||||
|
expect(shadow(overlaid, '.scrim')).toBeTruthy();
|
||||||
|
|
||||||
|
const close = shadow(overlaid, '[data-testid="queue-close"]');
|
||||||
|
|
||||||
|
expect(close?.getAttribute('aria-label')).toBe('Close queue');
|
||||||
|
|
||||||
|
const inline = await panelIn(1080);
|
||||||
|
|
||||||
|
expect(inline.shadowRoot?.querySelector('.scrim')).toBeNull();
|
||||||
|
expect(
|
||||||
|
inline.shadowRoot?.querySelector('[data-testid="queue-close"]'),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('closes on the scrim, on the close button and on Escape', async () => {
|
||||||
|
for (const close of [
|
||||||
|
(el: QueuePanel) => shadow<HTMLElement>(el, '.scrim')?.click(),
|
||||||
|
(el: QueuePanel) =>
|
||||||
|
shadow<HTMLElement>(el, '[data-testid="queue-close"]')?.click(),
|
||||||
|
() =>
|
||||||
|
document.dispatchEvent(
|
||||||
|
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }),
|
||||||
|
),
|
||||||
|
]) {
|
||||||
|
const el = await panelIn(700);
|
||||||
|
|
||||||
|
expect(el.open).toBe(true);
|
||||||
|
|
||||||
|
close(el);
|
||||||
|
await el.updateComplete;
|
||||||
|
|
||||||
|
expect(el.open).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escape belongs to the overlay, not to the queue. An inline panel is
|
||||||
|
* beside the content rather than over it, so there is nothing to
|
||||||
|
* dismiss and the key has to reach whatever else wants it.
|
||||||
|
*/
|
||||||
|
it('leaves Escape alone while inline', async () => {
|
||||||
|
const el = await panelIn(1080);
|
||||||
|
|
||||||
|
document.dispatchEvent(
|
||||||
|
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }),
|
||||||
|
);
|
||||||
|
await el.updateComplete;
|
||||||
|
|
||||||
|
expect(el.open).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,363 @@
|
|||||||
|
/**
|
||||||
|
* Owned is plain; unowned is what gets marked.
|
||||||
|
*
|
||||||
|
* `explore-album-details` had this right for one tracklist and nothing
|
||||||
|
* else did: Explore's cards, the top-results row and the artist page's
|
||||||
|
* three card shapes all mixed owned and unowned with a small badge as
|
||||||
|
* the only difference — and drew a green tick on the *common* case,
|
||||||
|
* which is the treatment the album page's own green ticks were removed
|
||||||
|
* for.
|
||||||
|
*
|
||||||
|
* What is pinned here is the rule rather than any one surface, because
|
||||||
|
* the fault this replaced was eight call sites each holding their own
|
||||||
|
* version of it:
|
||||||
|
*
|
||||||
|
* - an owned thing draws **no badge at all**;
|
||||||
|
* - an unowned one is dimmed *and* says so in its accessible name,
|
||||||
|
* because dimming is a colour and cannot be the only signal;
|
||||||
|
* - ownership is a **file** (`localId`), never the catalog's
|
||||||
|
* `inLibrary` ratchet, which is a flag that happens to agree;
|
||||||
|
* - and a partly-held album says *how* partly, which is the one thing
|
||||||
|
* a tick cannot.
|
||||||
|
*/
|
||||||
|
import { beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
import { page } from 'vitest/browser';
|
||||||
|
|
||||||
|
import '@components/explore-view/explore-view';
|
||||||
|
import '@components/top-results-row/top-results-row';
|
||||||
|
import { flush, stub, resetHarness } from '@test/support/harness';
|
||||||
|
import { fixture, shadow, shadowAll, update } from '@test/support/render';
|
||||||
|
import { completenessStore } from '@store/completeness-store';
|
||||||
|
|
||||||
|
const SEARCH = 'explore.Service.SearchLocal';
|
||||||
|
const SHELVES = 'explore.Service.GetExploreShelves';
|
||||||
|
const COMPLETENESS = 'library.Library.GetAlbumsCompleteness';
|
||||||
|
|
||||||
|
/** A release group as the backend projects one. */
|
||||||
|
function album(
|
||||||
|
title: string,
|
||||||
|
{ localId = 0, inLibrary = false }: { localId?: number; inLibrary?: boolean },
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
mbid: `rg-${title}`,
|
||||||
|
title,
|
||||||
|
artistCredit: 'An Artist',
|
||||||
|
artistMbid: 'ar-1',
|
||||||
|
primaryType: 'Album',
|
||||||
|
firstReleaseDate: '1994-05-01',
|
||||||
|
popularity: 100,
|
||||||
|
listenerCount: 10,
|
||||||
|
secondaryTypes: [],
|
||||||
|
inLibrary,
|
||||||
|
localId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A recording as the backend projects one. */
|
||||||
|
function recording(
|
||||||
|
title: string,
|
||||||
|
{ localId = 0, inLibrary = false }: { localId?: number; inLibrary?: boolean },
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
mbid: `rec-${title}`,
|
||||||
|
title,
|
||||||
|
artistCredit: 'An Artist',
|
||||||
|
artistMbid: 'ar-1',
|
||||||
|
length: 200000,
|
||||||
|
popularity: 0,
|
||||||
|
listenerCount: 0,
|
||||||
|
inLibrary,
|
||||||
|
localId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mount Explore showing one page of results. */
|
||||||
|
async function exploreShowing(results: {
|
||||||
|
releaseGroups?: unknown[];
|
||||||
|
recordings?: unknown[];
|
||||||
|
artists?: unknown[];
|
||||||
|
}) {
|
||||||
|
stub(SHELVES, { shelves: [], state: 'ready' });
|
||||||
|
stub(SEARCH, {
|
||||||
|
artists: [],
|
||||||
|
releaseGroups: [],
|
||||||
|
recordings: [],
|
||||||
|
...results,
|
||||||
|
});
|
||||||
|
|
||||||
|
const el = await fixture('explore-view');
|
||||||
|
|
||||||
|
// A cached primary view only fetches on arrival, and the search is
|
||||||
|
// what these cards come from.
|
||||||
|
(el as unknown as { onViewActivate: () => void }).onViewActivate?.();
|
||||||
|
await update(el, { results: { artists: [], releaseGroups: [], recordings: [], ...results } });
|
||||||
|
await flush();
|
||||||
|
await el.updateComplete;
|
||||||
|
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetHarness();
|
||||||
|
stub(COMPLETENESS, {});
|
||||||
|
|
||||||
|
// The store is a singleton and caches an *answer*, including the
|
||||||
|
// absent one — which is the point, or 87% of a grid re-asks forever.
|
||||||
|
// Two tests in one file are two sessions as far as it is concerned,
|
||||||
|
// so a stale entry from the test above would otherwise decide the
|
||||||
|
// one below. Found by writing the assertion the wrong way round.
|
||||||
|
completenessStore.invalidate();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('an owned thing is plain', () => {
|
||||||
|
it('draws no badge on an album card it has files for', async () => {
|
||||||
|
const el = await exploreShowing({
|
||||||
|
releaseGroups: [album('Held', { localId: 7 })],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(shadowAll(el, '.album-card')).toHaveLength(1);
|
||||||
|
expect(shadow(el, '.album-card library-status-indicator')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('draws no badge on a track row it has a file for', async () => {
|
||||||
|
const el = await exploreShowing({
|
||||||
|
recordings: [recording('Held', { localId: 9 })],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(shadowAll(el, '.track-item')).toHaveLength(1);
|
||||||
|
expect(shadow(el, '.track-item library-status-indicator')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not dim it', async () => {
|
||||||
|
const el = await exploreShowing({
|
||||||
|
releaseGroups: [album('Held', { localId: 7 })],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(shadow(el, '.album-card')?.classList.contains('unowned')).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('an unowned thing is marked', () => {
|
||||||
|
it('dims the card and keeps its request badge', async () => {
|
||||||
|
const el = await exploreShowing({
|
||||||
|
releaseGroups: [album('Absent', {})],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(shadow(el, '.album-card')?.classList.contains('unowned')).toBe(true);
|
||||||
|
expect(shadow(el, '.album-card library-status-indicator')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The name is the half of this that reaches anyone not seeing the
|
||||||
|
* dimming, so it has to be the browser's own answer — a shadow-root
|
||||||
|
* query cannot compute a name, and this repo has shipped a nameless
|
||||||
|
* control three times.
|
||||||
|
*/
|
||||||
|
it('says so in the name the browser computes', async () => {
|
||||||
|
await exploreShowing({ releaseGroups: [album('Absent', {})] });
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.element(page.getByRole('button', { name: /Absent — not in your library/ }))
|
||||||
|
.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A track row is `aria-disabled` and a card is not, and the
|
||||||
|
* difference is not cosmetic: activating an unowned row does nothing
|
||||||
|
* (`onRecordingRowDblClick` returns early), while a card navigates to
|
||||||
|
* the catalog page for it, which is a perfectly good thing to do with
|
||||||
|
* something you do not own.
|
||||||
|
*/
|
||||||
|
it('marks a row that cannot be played as disabled', async () => {
|
||||||
|
const el = await exploreShowing({
|
||||||
|
recordings: [recording('Absent', {})],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(shadow(el, '.track-item')?.getAttribute('aria-disabled')).toBe(
|
||||||
|
'true',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves a card that still navigates enabled', async () => {
|
||||||
|
const el = await exploreShowing({
|
||||||
|
releaseGroups: [album('Absent', {})],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(shadow(el, '.album-card')?.getAttribute('aria-disabled')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The decision this issue turned on.
|
||||||
|
*
|
||||||
|
* `inLibrary` is written by the same pass that writes the local ids, so
|
||||||
|
* the two agree in a healthy database — but it is a one-way ratchet
|
||||||
|
* (`MAX(in_library, excluded.in_library)`) whose only clearing pass is
|
||||||
|
* gated on a non-null `local_*_id`, so it cannot be un-set on its own.
|
||||||
|
* A row carrying it with no local id behind it is a claim of ownership
|
||||||
|
* with no file, which is exactly what the album page refuses to trust.
|
||||||
|
*/
|
||||||
|
describe('ownership is a file, not a flag', () => {
|
||||||
|
it('treats a card flagged inLibrary with no local row as unowned', async () => {
|
||||||
|
const el = await exploreShowing({
|
||||||
|
releaseGroups: [album('Phantom', { inLibrary: true, localId: 0 })],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(shadow(el, '.album-card')?.classList.contains('unowned')).toBe(true);
|
||||||
|
expect(shadow(el, '.album-card library-status-indicator')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does the same for a track row', async () => {
|
||||||
|
const el = await exploreShowing({
|
||||||
|
recordings: [recording('Phantom', { inLibrary: true, localId: 0 })],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(shadow(el, '.track-item')?.getAttribute('aria-disabled')).toBe(
|
||||||
|
'true',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The count, which is what `#16`'s deferred third step asked for: an
|
||||||
|
* album held 2 tracks of 10 wore the same green tick as one held whole,
|
||||||
|
* on every grid in the app.
|
||||||
|
*/
|
||||||
|
describe('a partly-held album says how partly', () => {
|
||||||
|
it('draws the ring and puts the count in the badge name', async () => {
|
||||||
|
stub(COMPLETENESS, {
|
||||||
|
'7': { owned: 9, expected: 12, known: true, complete: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
const el = await exploreShowing({
|
||||||
|
releaseGroups: [album('Partly', { localId: 7 })],
|
||||||
|
});
|
||||||
|
|
||||||
|
// The store batches into the next frame, so the answer lands one
|
||||||
|
// repaint after the cards do — which is the thing the subscription
|
||||||
|
// exists for.
|
||||||
|
await new Promise((r) => requestAnimationFrame(() => r(null)));
|
||||||
|
await flush();
|
||||||
|
await el.updateComplete;
|
||||||
|
|
||||||
|
const badge = shadow(el, '.album-card library-status-indicator');
|
||||||
|
|
||||||
|
expect(badge?.getAttribute('status')).toBe('partial');
|
||||||
|
|
||||||
|
// A partly-held album is *actionable* — it has three tracks left to
|
||||||
|
// ask for — so the badge is a button, and the name has to carry the
|
||||||
|
// action and the count. Naming it after the action alone left the
|
||||||
|
// one state the ring exists for as the one state whose name did not
|
||||||
|
// mention it.
|
||||||
|
await expect
|
||||||
|
.element(
|
||||||
|
page.getByRole('button', {
|
||||||
|
name: /Request the rest of album .*Partly.* — 9 of 12 tracks/,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where the tags never declared a total, `known` is false and the
|
||||||
|
* card must say nothing — most of an untagged library is in that
|
||||||
|
* state, and a ring drawn from its absence would mark all of it
|
||||||
|
* incomplete on no evidence. That is the rule `Known` exists for.
|
||||||
|
*/
|
||||||
|
it('says nothing when the total was never declared', async () => {
|
||||||
|
stub(COMPLETENESS, {
|
||||||
|
'7': { owned: 3, expected: 0, known: false, complete: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
const el = await exploreShowing({
|
||||||
|
releaseGroups: [album('Untotalled', { localId: 7 })],
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise((r) => requestAnimationFrame(() => r(null)));
|
||||||
|
await flush();
|
||||||
|
await el.updateComplete;
|
||||||
|
|
||||||
|
expect(shadow(el, '.album-card library-status-indicator')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('asks about the owned albums only, in one call', async () => {
|
||||||
|
const seen: unknown[][] = [];
|
||||||
|
|
||||||
|
stub(COMPLETENESS, (...args: unknown[]) => {
|
||||||
|
seen.push(args);
|
||||||
|
|
||||||
|
return {};
|
||||||
|
});
|
||||||
|
|
||||||
|
await exploreShowing({
|
||||||
|
releaseGroups: [
|
||||||
|
album('Held', { localId: 7 }),
|
||||||
|
album('Also held', { localId: 8 }),
|
||||||
|
album('Absent', {}),
|
||||||
|
album('Phantom', { inLibrary: true, localId: 0 }),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise((r) => requestAnimationFrame(() => r(null)));
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(seen).toHaveLength(1);
|
||||||
|
expect(seen[0]?.[0]).toEqual([7, 8]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('the top-results row follows the same rule', () => {
|
||||||
|
const result = (
|
||||||
|
name: string,
|
||||||
|
entityType: string,
|
||||||
|
extra: Record<string, unknown> = {},
|
||||||
|
) => ({
|
||||||
|
entityType,
|
||||||
|
mbid: `top-${name}`,
|
||||||
|
name,
|
||||||
|
artistCredit: 'An Artist',
|
||||||
|
intentScore: 1,
|
||||||
|
inLibrary: false,
|
||||||
|
...extra,
|
||||||
|
});
|
||||||
|
|
||||||
|
it('draws no badge on something it owns', async () => {
|
||||||
|
const el = await fixture('top-results-row', {
|
||||||
|
results: [result('Held', 'release_group', { localId: 7 })],
|
||||||
|
query: 'held',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(shadow(el, '.card library-status-indicator')).toBeNull();
|
||||||
|
expect(shadow(el, '.card')?.classList.contains('unowned')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dims and names something it does not', async () => {
|
||||||
|
const el = await fixture('top-results-row', {
|
||||||
|
results: [result('Absent', 'release_group')],
|
||||||
|
query: 'absent',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(shadow(el, '.card')?.classList.contains('unowned')).toBe(true);
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.element(page.getByRole('button', { name: /Absent — not in your library/ }))
|
||||||
|
.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An artist card has never had a badge — a discography subscription
|
||||||
|
* is the artist page's Follow button, which can say what it commits
|
||||||
|
* to — so the dimming and the name are the whole signal there.
|
||||||
|
*/
|
||||||
|
it('marks an unowned artist without offering a request', async () => {
|
||||||
|
const el = await fixture('top-results-row', {
|
||||||
|
results: [result('An Artist', 'artist')],
|
||||||
|
query: 'an artist',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(shadow(el, '.card')?.classList.contains('unowned')).toBe(true);
|
||||||
|
expect(shadow(el, '.card library-status-indicator')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
+10
-1
@@ -63,8 +63,17 @@ pre-commit:
|
|||||||
root: "frontend/"
|
root: "frontend/"
|
||||||
run: node scripts/check-css-literals.mjs
|
run: node scripts/check-css-literals.mjs
|
||||||
|
|
||||||
|
# Deliberately sequential, unlike pre-commit. `go test -race`
|
||||||
|
# saturates every core for the better part of a minute and the UI tier
|
||||||
|
# is a real browser with wall-clock timeouts, so run together the
|
||||||
|
# browser loses: setup took 106s inside the hook against 63s
|
||||||
|
# standalone, and a different suite failed each time -- three suites
|
||||||
|
# failing to fetch setup.ts from Vitest's own dev server on one run, a
|
||||||
|
# 15s "did not mount itself" on the next, against a suite that passes
|
||||||
|
# 898/898 on its own. A gate that fails at random is not a gate. The
|
||||||
|
# ~15s saved is not worth it.
|
||||||
pre-push:
|
pre-push:
|
||||||
parallel: true
|
parallel: false
|
||||||
commands:
|
commands:
|
||||||
go-test:
|
go-test:
|
||||||
glob: "*.go"
|
glob: "*.go"
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
# YellowJacket Arch package
|
# YellowJacket Arch package
|
||||||
|
|
||||||
This directory holds the `PKGBUILD` and desktop entry used to build the
|
This directory holds the `PKGBUILD` and desktop entry used to build the
|
||||||
Arch Linux package. CI builds it on every push to `main` (see
|
Arch Linux package. CI builds it on every **release tag** (see
|
||||||
`.gitea/workflows/arch-package.yml`) and publishes it to the Gitea Arch
|
`.gitea/workflows/arch-package.yml`) and publishes it to the Gitea Arch
|
||||||
package registry, from which pacman can install it directly.
|
package registry, from which pacman can install it directly.
|
||||||
|
|
||||||
|
Tags come from `.gitea/workflows/release.yml`, which is run by hand — it
|
||||||
|
used to fire on every push to `main`, which meant a new package per
|
||||||
|
merged PR (issue #115). A prerelease tag (`v0.4.0-beta.1`) is skipped:
|
||||||
|
the workflow's trigger is `v*` and matches one.
|
||||||
|
|
||||||
## Installing from the registry
|
## Installing from the registry
|
||||||
|
|
||||||
The registry is public — no login required.
|
The registry is public — no login required.
|
||||||
@@ -58,7 +63,7 @@ this is not recommended.
|
|||||||
- Only the runtime package is published; the `-debug` package makepkg
|
- Only the runtime package is published; the `-debug` package makepkg
|
||||||
produces (detached symbols) is skipped by the workflow.
|
produces (detached symbols) is skipped by the workflow.
|
||||||
- Package versions come from `pkgver()` in the PKGBUILD, derived from git
|
- Package versions come from `pkgver()` in the PKGBUILD, derived from git
|
||||||
(e.g. `1.3.0.r173.g4ae5ffc-1`), so every push to `main` yields a new
|
(e.g. `1.3.0.r173.g4ae5ffc-1`), so every release tag yields a new
|
||||||
version.
|
version.
|
||||||
- Repo priority: if another configured repo ever provides a package named
|
- Repo priority: if another configured repo ever provides a package named
|
||||||
`yellowjacket`, the repo listed **first** in `pacman.conf` wins. Force a
|
`yellowjacket`, the repo listed **first** in `pacman.conf` wins. Force a
|
||||||
|
|||||||
Reference in New Issue
Block a user