Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion .github/workflows/cd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -75,9 +75,12 @@ jobs:
if: runner.os == 'Linux'
run: cargo install cargo-deb --locked

# Static portaudio, so the install.sh binary runs without Homebrew (checked below).
- name: Install macOS dependencies
if: runner.os == 'macOS'
run: brew install openssl@3 portaudio
run: |
brew install openssl@3 portaudio
echo "PKG_CONFIG_ALL_STATIC=1" >> "$GITHUB_ENV"

- name: Build release binary
run: |
Expand All @@ -88,6 +91,17 @@ jobs:
fi
shell: bash

- name: Check the macOS binary links only system libraries
if: runner.os == 'macOS'
run: |
bin=target/${{ matrix.target }}/release/${{ env.BINARY_NAME }}
otool -L "$bin"
foreign=$(otool -L "$bin" | tail -n +2 | awk '{print $1}' | grep -v -e '^/System/' -e '^/usr/lib/' || true)
if [ -n "$foreign" ]; then
echo "::error::$bin links libraries outside /System and /usr/lib: $foreign"
exit 1
fi

- name: Package binary (Unix)
if: runner.os != 'Windows'
shell: bash
Expand Down
2 changes: 1 addition & 1 deletion docs/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,7 @@ sudo dnf install pipewire

### macOS

spotatui uses the `portaudio` backend for better stability and bluetooth device support (AirPods, etc.):
spotatui uses the `portaudio` backend for better stability and bluetooth device support (AirPods, etc.). The prebuilt binaries link it statically, so you only need it to build from source:

```bash
brew install portaudio
Expand Down
18 changes: 18 additions & 0 deletions gui/e2e/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,22 @@ export const onboarding: ServerMessage = {
},
};

export const onboardingConfirm: ServerMessage = {
kind: "onboarding",
payload: {
transcript: "Welcome to spotatui.\n",
pending: {
seq: 2,
ask: {
kind: "Confirm",
title: "Global Song Counter",
body: "spotatui can contribute to a global counter showing total songs played by all users worldwide.\n\nPrivacy: This feature is completely anonymous.\n• No personal information is collected\n• No song names, artists, or listening history\n• Only a simple increment once a song has played for 30 seconds",
question: "Would you like to participate?",
},
},
},
};

function track(
name: string,
seconds: number,
Expand Down Expand Up @@ -286,6 +302,7 @@ export const library: ServerMessage[] = [
saved_albums: [],
saved_albums_has_more: false,
followed_artists: [],
followed_artists_has_more: false,
saved_shows: [],
saved_shows_has_more: false,
saved_show_ids: [],
Expand Down Expand Up @@ -336,6 +353,7 @@ export const libraryUnavailable: ServerMessage[] = [
saved_albums: [],
saved_albums_has_more: false,
followed_artists: [],
followed_artists_has_more: false,
saved_shows: [],
saved_shows_has_more: false,
saved_show_ids: [],
Expand Down
7 changes: 7 additions & 0 deletions gui/e2e/scenes.shot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
libraryFolders,
libraryUnavailable,
onboarding,
onboardingConfirm,
playing,
podcast,
podcastSearch,
Expand Down Expand Up @@ -60,6 +61,12 @@ test("onboarding", async ({ page }) => {
await shot(page, "onboarding");
});

test("onboarding question", async ({ page }) => {
await openWith(page, [hello, onboardingConfirm]);
await page.getByText("Would you like to participate?").waitFor();
await shot(page, "onboarding-question");
});

test("playing", async ({ page }) => {
await openWith(page, [...playing, ...library]);
await page.getByText("Set Fire to the Rain").first().waitFor();
Expand Down
27 changes: 12 additions & 15 deletions gui/src/LibraryPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ import {
import { createPortal } from "react-dom";
import type { Action } from "./bindings/Action";
import type { ArtistInfo } from "./bindings/ArtistInfo";
import type { ListTarget } from "./bindings/ListTarget";
import type { PlaylistSyncPayload } from "./bindings/PlaylistSyncPayload";
import type { ShowInfo } from "./bindings/ShowInfo";
import type { Source } from "./bindings/Source";
Expand Down Expand Up @@ -44,6 +43,7 @@ import {
sortAlbums,
sortArtists,
sortChoice,
libraryPager,
wantsNextPage,
type PlaylistRemoval,
type PlaylistRemovalTarget,
Expand Down Expand Up @@ -81,6 +81,7 @@ export const LibraryPanel = memo(function LibraryPanel({
albumSort,
artistSort,
loggedOut = false,
errorRev = null,
}: {
s?: GuiStrings;
playlists: SourcePlaylists | null;
Expand All @@ -98,6 +99,8 @@ export const LibraryPanel = memo(function LibraryPanel({
artistSort?: string | null;
/** True when the server says there is no Spotify session. */
loggedOut?: boolean;
/** The revision of the live error status; null without one. */
errorRev?: number | null;
}) {
const active = source?.active ?? null;
const spotify = active === "Spotify";
Expand Down Expand Up @@ -135,20 +138,14 @@ export const LibraryPanel = memo(function LibraryPanel({
: ["Playlists"];
const compiled = source?.compiled ?? [];

const pageTarget: ListTarget | null =
filter === "Albums"
? "SavedAlbums"
: filter === "Podcasts"
? "SavedShows"
: null;
const pageList =
pageTarget === "SavedAlbums"
? playlists?.saved_albums
: playlists?.saved_shows;
const pageMore =
(pageTarget === "SavedAlbums"
? playlists?.saved_albums_has_more
: playlists?.saved_shows_has_more) ?? false;
const pager = libraryPager(filter, playlists);
const pageTarget = pager?.target ?? null;
const pageList = pager?.list;
const pageMore = pager?.more ?? false;
// A failed fetch pushes only an error status, so the next scroll may ask again.
useEffect(() => {
if (errorRev !== null) askedFor.current = null;
}, [errorRev]);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
const nextPage = useCallback(() => {
const box = rowsBox.current;
if (!pageTarget || !box) return;
Expand Down
12 changes: 12 additions & 0 deletions gui/src/Onboarding.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,18 @@ describe("Onboarding", () => {
expect(html).toContain(">No<");
});

it("shows the question as text with a primary yes, not a console block", () => {
const html = render({
kind: "Confirm",
title: "Song counter",
body: "Count plays?",
question: "Join?",
});
expect(html).toContain("<p>Count plays?</p>");
expect(html).not.toContain("<pre>Count plays?");
expect(html).toContain('class="onboarding-primary"');
});

it("lists every offered source as a checkbox", () => {
const html = render({
kind: "PickSources",
Expand Down
39 changes: 21 additions & 18 deletions gui/src/Onboarding.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,26 +71,29 @@ function Question({
);
case "Confirm":
return (
<div>
<div className="onboarding-question">
<h2>{ask.title}</h2>
<pre>{ask.body}</pre>
<p>{ask.body}</p>
<p>{ask.question}</p>
<button
disabled={!connected}
onClick={() =>
onReply({ seq, answer: { kind: "Confirm", yes: true } })
}
>
{s.yes}
</button>
<button
disabled={!connected}
onClick={() =>
onReply({ seq, answer: { kind: "Confirm", yes: false } })
}
>
{s.no}
</button>
<div className="onboarding-actions">
<button
className="onboarding-primary"
disabled={!connected}
onClick={() =>
onReply({ seq, answer: { kind: "Confirm", yes: true } })
}
>
{s.yes}
</button>
<button
disabled={!connected}
onClick={() =>
onReply({ seq, answer: { kind: "Confirm", yes: false } })
}
>
{s.no}
</button>
</div>
</div>
);
case "PickSources":
Expand Down
44 changes: 38 additions & 6 deletions gui/src/PodcastPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import { Glyph } from "./Icon";
import { clampCursor, step } from "./libraryModel";
import type { OpenView } from "./nav";
import "./Pages.css";
import { copyLink } from "./pagesModel";
import { copyLink, loadLatch, type LoadLatch } from "./pagesModel";
import "./PodcastPage.css";
import {
episodeDate,
Expand All @@ -40,10 +40,15 @@ const NO_EPISODES: EpisodeInfo[] = [];

export interface PodcastPageProps {
s?: GuiStrings;
/** The open view; a new one is a fresh open, even of the same show. */
view: object;
id: string;
name: string;
/** The last fetched show, which can still be another one. */
show: ShowPayload | null;
/** The status revision and whether it is an error: a later error means the fetch failed. */
statusRev: number | null;
statusError: boolean;
playingUri: string | null;
active: boolean;
open: OpenView;
Expand All @@ -53,30 +58,57 @@ export interface PodcastPageProps {
/** A podcast show with its episodes. */
export const PodcastPage = memo(function PodcastPage({
s = EN,
view,
id,
name,
show,
statusRev,
statusError,
playingUri,
active,
send,
}: PodcastPageProps) {
const readStrings = useStringsReader(s);
const [want, setWant] = useState(0);
const [expanded, setExpanded] = useState(false);
const [shownId, setShownId] = useState(id);
if (shownId !== id) {
setShownId(id);
const [shownFor, setShownFor] = useState(view);
const [latch, setLatch] = useState<LoadLatch>({
openedAt: statusRev,
failedId: null,
});
const fresh = shownFor !== view;
if (fresh) {
setShownFor(view);
setWant(0);
setExpanded(false);
}
const page = show?.page?.show.id === id ? show.page : null;
const landed = page !== null && page.loaded;
const next = loadLatch(latch, {
id,
fresh,
landed,
statusRev,
statusError,
});
if (next.openedAt !== latch.openedAt || next.failedId !== latch.failedId)
setLatch(next);
const loading =
!landed && next.failedId === id
? fmt(s.load_playlist_failed, { name: page?.show.name ?? name })
: s.loading_episodes;
const episodes = page?.episodes ?? NO_EPISODES;
const cursor = clampCursor(want, episodes.length);

const [pager] = useState(episodePager);
useEffect(() => {
pager.land(id, episodes.length);
}, [pager, id, episodes.length]);
// A failed page pushes only an error status: the next move or scroll asks again.
const errorRev = statusError ? statusRev : null;
useEffect(() => {
if (errorRev !== null) pager.retry();
}, [pager, errorRev]);
const loadMore = useCallback(() => {
if (active && page && pager.ask(page)) send({ LoadMore: "ShowEpisodes" });
}, [active, page, pager, send]);
Expand Down Expand Up @@ -110,7 +142,7 @@ export const PodcastPage = memo(function PodcastPage({
return (
<section className="podcast-page" aria-label={name}>
<PageHeader kind={s.podcast} title={name} image={null} />
<p className="podcast-empty">{s.loading_episodes}</p>
<p className="podcast-empty">{loading}</p>
</section>
);

Expand Down Expand Up @@ -142,7 +174,7 @@ export const PodcastPage = memo(function PodcastPage({
<h2>{s.all_episodes}</h2>
{episodes.length === 0 ? (
<p className="podcast-empty">
{page.loaded ? s.show_no_episodes : s.loading_episodes}
{page.loaded ? s.show_no_episodes : loading}
</p>
) : (
<div
Expand Down
29 changes: 22 additions & 7 deletions gui/src/RadioPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,16 @@ import { clampCursor, playRequest, songCount } from "./libraryModel";
import "./ListPage.css";
import type { OpenView } from "./nav";
import "./Pages.css";
import { radioRows, trackKey } from "./pagesModel";
import { loadLatch, radioRows, trackKey, type LoadLatch } from "./pagesModel";
import { TrackTable } from "./TrackTable";
import { PageHeader, PlayButton } from "./ui";

const NO_TRACKS: TrackInfo[] = [];

export interface RadioPageProps {
s?: GuiStrings;
/** The open view; a new one is a fresh open, even of the same seed. */
view: object;
/** The seed track id. */
id: string;
name: string;
Expand All @@ -34,6 +36,7 @@ export interface RadioPageProps {
/** The tracks Spotify recommends from one song. */
export function RadioPage({
s = EN,
view,
id,
name,
table,
Expand All @@ -45,16 +48,28 @@ export function RadioPage({
send,
}: RadioPageProps) {
const [want, setWant] = useState(0);
const [shownId, setShownId] = useState(id);
const [openedAt, setOpenedAt] = useState(statusRev);
if (shownId !== id) {
setShownId(id);
const [shownFor, setShownFor] = useState(view);
const [latch, setLatch] = useState<LoadLatch>({
openedAt: statusRev,
failedId: null,
});
const fresh = shownFor !== view;
if (fresh) {
setShownFor(view);
setWant(0);
setOpenedAt(statusRev);
}

const rows = radioRows(table, id);
const failed = !rows && statusError && statusRev !== openedAt;
const next = loadLatch(latch, {
id,
fresh,
landed: rows !== null,
statusRev,
statusError,
});
if (next.openedAt !== latch.openedAt || next.failedId !== latch.failedId)
setLatch(next);
const failed = !rows && next.failedId === id;
const tracks = rows ?? NO_TRACKS;
const cursor = clampCursor(want, tracks.length);
const play = (index: number) => {
Expand Down
Loading
Loading