Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e09ae15e32 | |||
| 33af131b77 | |||
| 3d6d4918bc | |||
| 35ad858d0d | |||
| af827a9a96 |
15
.github/workflows/release.yml
vendored
15
.github/workflows/release.yml
vendored
@@ -50,10 +50,7 @@ jobs:
|
|||||||
- name: Install fftw (cavacore build dependency)
|
- name: Install fftw (cavacore build dependency)
|
||||||
run: |
|
run: |
|
||||||
if uname -s | grep -qi darwin; then
|
if uname -s | grep -qi darwin; then
|
||||||
# mpv is required for the release bundle: build.ts copies it into
|
brew install fftw
|
||||||
# PodTui.app (signed with the podtui bundle identifier) so macOS
|
|
||||||
# Now Playing shows the PodTui icon instead of a blank placeholder.
|
|
||||||
brew install fftw mpv
|
|
||||||
else
|
else
|
||||||
sudo apt-get update
|
sudo apt-get update
|
||||||
sudo apt-get install -y libfftw3-dev
|
sudo apt-get install -y libfftw3-dev
|
||||||
@@ -79,16 +76,6 @@ jobs:
|
|||||||
printf 'preload = ["./definitely-missing.ts"]\n' > "$SMOKE_DIR/bunfig.toml"
|
printf 'preload = ["./definitely-missing.ts"]\n' > "$SMOKE_DIR/bunfig.toml"
|
||||||
cd "$SMOKE_DIR"
|
cd "$SMOKE_DIR"
|
||||||
./podtui-*/podtui --version
|
./podtui-*/podtui --version
|
||||||
# macOS tarballs must ship PodTui.app with a working bundled mpv
|
|
||||||
# carrying the podtui bundle identifier — otherwise Now Playing
|
|
||||||
# attribution silently regresses to a blank icon.
|
|
||||||
if [ "${{ matrix.plat }}" = "darwin" ]; then
|
|
||||||
MPV=./podtui-*/PodTui.app/Contents/MacOS/mpv
|
|
||||||
test -x $MPV || { echo "PodTui.app missing bundled mpv"; exit 1; }
|
|
||||||
$MPV --version >/dev/null || { echo "bundled mpv does not launch"; exit 1; }
|
|
||||||
codesign -dvv $MPV 2>&1 | grep -q "Identifier=com.mikefreno.podtui" \
|
|
||||||
|| { echo "bundled mpv lacks podtui signing identifier"; exit 1; }
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Upload artifact
|
- name: Upload artifact
|
||||||
uses: actions/upload-artifact@v6
|
uses: actions/upload-artifact@v6
|
||||||
|
|||||||
@@ -54,14 +54,6 @@ Linux (arm64/x64). Pick whichever fits your platform.
|
|||||||
brew install mikefreno/tap/podtui
|
brew install mikefreno/tap/podtui
|
||||||
```
|
```
|
||||||
|
|
||||||
On macOS the tarball also ships a `PodTui.app` bundle. PodTui plays audio
|
|
||||||
through a copy of mpv that lives **inside the bundle**, so macOS attributes
|
|
||||||
the Now Playing session to PodTui — the Control Center / lock-screen entry
|
|
||||||
shows the PodTui name and icon, and podcast cover art as its artwork —
|
|
||||||
rather than a blank placeholder for an unbundled binary. Installers can drop
|
|
||||||
`PodTui.app` into `/Applications`; the `podtui` entry point should point at
|
|
||||||
`PodTui.app/Contents/MacOS/podtui` so the bundled mpv is used.
|
|
||||||
|
|
||||||
### 2. Standalone tarball (all platforms)
|
### 2. Standalone tarball (all platforms)
|
||||||
|
|
||||||
Grab `podtui-<platform>-<arch>.tar.gz` from the latest
|
Grab `podtui-<platform>-<arch>.tar.gz` from the latest
|
||||||
|
|||||||
Binary file not shown.
124
build.ts
124
build.ts
@@ -131,130 +131,6 @@ if (COMPILE) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// macOS app bundle: PodTui.app. We run our audio backend (mpv) from
|
|
||||||
// INSIDE the bundle (Contents/MacOS/mpv) so macOS attributes its Now
|
|
||||||
// Playing session to PodTui — the source-app icon + name in Control
|
|
||||||
// Center / lock screen — instead of a blank placeholder for an
|
|
||||||
// unbundled binary. AudioPlayer's resolver prefers this sibling.
|
|
||||||
if (platform === "darwin") {
|
|
||||||
const appRoot = join(tarRoot, "PodTui.app");
|
|
||||||
const macosDir = join(appRoot, "Contents", "MacOS");
|
|
||||||
const resDir = join(appRoot, "Contents", "Resources");
|
|
||||||
mkdirSync(macosDir, { recursive: true });
|
|
||||||
mkdirSync(resDir, { recursive: true });
|
|
||||||
|
|
||||||
copyFileSync(outfile, join(macosDir, "podtui"));
|
|
||||||
for (const lib of [`libopentui.${libExt}`, cavacoreLib]) {
|
|
||||||
const s = join("dist", lib);
|
|
||||||
if (existsSync(s)) copyFileSync(s, join(macosDir, lib));
|
|
||||||
}
|
|
||||||
|
|
||||||
const mpvResolve = Bun.spawnSync(["which", "mpv"]);
|
|
||||||
const mpvPath =
|
|
||||||
mpvResolve.exitCode === 0 ? mpvResolve.stdout.toString().trim() : "";
|
|
||||||
if (mpvPath) {
|
|
||||||
copyFileSync(mpvPath, join(macosDir, "mpv"));
|
|
||||||
} else {
|
|
||||||
// A darwin release tarball without a bundled mpv silently ships
|
|
||||||
// without Now Playing attribution (blank icon). Fail loudly so CI
|
|
||||||
// can't produce it — the runner must have mpv installed.
|
|
||||||
console.error(
|
|
||||||
"Error: mpv not found in PATH — PodTui.app requires a bundled mpv for macOS Now Playing attribution (brew install mpv on the build machine)",
|
|
||||||
);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const icnsSrc = join("assets", "App Icon", "AppIcon.icns");
|
|
||||||
if (existsSync(icnsSrc)) {
|
|
||||||
copyFileSync(icnsSrc, join(resDir, "AppIcon.icns"));
|
|
||||||
} else {
|
|
||||||
console.warn(
|
|
||||||
"Warning: assets/App Icon/AppIcon.icns missing — app bundle has no icon",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Version for the bundle comes from src/index.tsx (single source of
|
|
||||||
// truth — release.yml requires bumping it in the tag commit).
|
|
||||||
const srcIndex = await Bun.file(join("src", "index.tsx")).text();
|
|
||||||
const versionMatch = srcIndex.match(/const VERSION = "([^"]+)"/);
|
|
||||||
const bundleVersion = versionMatch?.[1];
|
|
||||||
if (!bundleVersion) {
|
|
||||||
console.error("Error: could not read VERSION from src/index.tsx");
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
Bun.write(
|
|
||||||
join(appRoot, "Contents", "Info.plist"),
|
|
||||||
`<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
||||||
<plist version="1.0">
|
|
||||||
<dict>
|
|
||||||
<key>CFBundleName</key>
|
|
||||||
<string>PodTui</string>
|
|
||||||
<key>CFBundleDisplayName</key>
|
|
||||||
<string>PodTui</string>
|
|
||||||
<key>CFBundleIdentifier</key>
|
|
||||||
<string>com.mikefreno.podtui</string>
|
|
||||||
<key>CFBundleExecutable</key>
|
|
||||||
<string>podtui</string>
|
|
||||||
<key>CFBundlePackageType</key>
|
|
||||||
<string>APPL</string>
|
|
||||||
<key>CFBundleIconFile</key>
|
|
||||||
<string>AppIcon</string>
|
|
||||||
<key>CFBundleShortVersionString</key>
|
|
||||||
<string>${bundleVersion}</string>
|
|
||||||
<key>CFBundleVersion</key>
|
|
||||||
<string>${bundleVersion}</string>
|
|
||||||
<key>LSMinimumSystemVersion</key>
|
|
||||||
<string>12.0</string>
|
|
||||||
</dict>
|
|
||||||
</plist>
|
|
||||||
`,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Ad-hoc sign so the bundle launches cleanly on fresh machines.
|
|
||||||
// Identity overridable via PODTUI_CODESIGN_IDENTITY (e.g. a Developer
|
|
||||||
// ID cert for release builds); default ad-hoc.
|
|
||||||
const signIdentity = process.env.PODTUI_CODESIGN_IDENTITY || "-";
|
|
||||||
const sign = Bun.spawnSync([
|
|
||||||
"codesign",
|
|
||||||
"--force",
|
|
||||||
"--deep",
|
|
||||||
"-s",
|
|
||||||
signIdentity,
|
|
||||||
appRoot,
|
|
||||||
]);
|
|
||||||
if (sign.exitCode !== 0) {
|
|
||||||
console.warn(
|
|
||||||
`Warning: codesign failed (${sign.stderr.toString().trim()}) — app bundle unsigned`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sign the nested mpv LAST with our bundle identifier. mediaremoted
|
|
||||||
// resolves the Now Playing client from the registering process's
|
|
||||||
// code-signing identifier — without an explicit --identifier codesign
|
|
||||||
// stamps "mpv" (its basename) and the audio center shows a blank
|
|
||||||
// placeholder. Must run after the bundle sign above (a later bundle
|
|
||||||
// re-seal would re-derive the basename identifier).
|
|
||||||
const signMpv = Bun.spawnSync([
|
|
||||||
"codesign",
|
|
||||||
"--force",
|
|
||||||
"-s",
|
|
||||||
signIdentity,
|
|
||||||
"--identifier",
|
|
||||||
"com.mikefreno.podtui",
|
|
||||||
join(macosDir, "mpv"),
|
|
||||||
]);
|
|
||||||
if (signMpv.exitCode !== 0) {
|
|
||||||
console.warn(
|
|
||||||
`Warning: nested mpv signing failed (${signMpv.stderr
|
|
||||||
.toString()
|
|
||||||
.trim()}) — Now Playing attribution won't work`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
console.log(`App bundle: ${appRoot}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const tar = Bun.spawnSync([
|
const tar = Bun.spawnSync([
|
||||||
"tar",
|
"tar",
|
||||||
"-czf",
|
"-czf",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { Feed } from "./types/feed"
|
import type { Feed } from "./types/feed"
|
||||||
import type { Episode } from "./types/episode"
|
import type { Episode } from "./types/episode"
|
||||||
|
|
||||||
const VERSION = "0.5.0";
|
const VERSION = "0.5.2";
|
||||||
|
|
||||||
interface CliArgs {
|
interface CliArgs {
|
||||||
version: boolean;
|
version: boolean;
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ function FeedPage() {
|
|||||||
// loaded window by 50 episodes. manual mode: Enter on the row. auto mode:
|
// loaded window by 50 episodes. manual mode: Enter on the row. auto mode:
|
||||||
// reaching the bottom row fetches automatically (see the effect below).
|
// reaching the bottom row fetches automatically (see the effect below).
|
||||||
const app = useAppStore();
|
const app = useAppStore();
|
||||||
const fetchMoreMode = () => app.state().preferences.fetchMoreMode ?? "manual";
|
const fetchMoreMode = () => app.state().preferences.fetchMoreMode ?? "auto";
|
||||||
const showFetchMore = () => feedStore.hasMoreAcrossAll();
|
const showFetchMore = () => feedStore.hasMoreAcrossAll();
|
||||||
// Total navigable rows: episodes + the optional Fetch More row.
|
// Total navigable rows: episodes + the optional Fetch More row.
|
||||||
const rowCount = () => episodes().length + (showFetchMore() ? 1 : 0);
|
const rowCount = () => episodes().length + (showFetchMore() ? 1 : 0);
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ export function MyShowsPage() {
|
|||||||
// counterpart to the Feed page's row (which loads every feed). manual
|
// counterpart to the Feed page's row (which loads every feed). manual
|
||||||
// mode: Enter on the row. auto mode: reaching the bottom row fetches
|
// mode: Enter on the row. auto mode: reaching the bottom row fetches
|
||||||
// automatically (see the effect below).
|
// automatically (see the effect below).
|
||||||
const fetchMoreMode = () => app.state().preferences.fetchMoreMode ?? "manual";
|
const fetchMoreMode = () => app.state().preferences.fetchMoreMode ?? "auto";
|
||||||
const showFetchMore = () =>
|
const showFetchMore = () =>
|
||||||
depth() >= 1 &&
|
depth() >= 1 &&
|
||||||
!!drilledShowId() &&
|
!!drilledShowId() &&
|
||||||
|
|||||||
@@ -211,10 +211,10 @@ export function usePreferencesItems(): SettingItem[] {
|
|||||||
kind: "select",
|
kind: "select",
|
||||||
display: () => (prefs().fetchMoreMode === "auto" ? "Auto" : "Manual"),
|
display: () => (prefs().fetchMoreMode === "auto" ? "Auto" : "Manual"),
|
||||||
help: () =>
|
help: () =>
|
||||||
`How the Feed and per-show episode lists load older episodes.\nManual: a "[Fetch More]" button at the bottom of the list.\nAuto: fetches automatically when reaching the bottom.\nType: select\nDefault: manual\nCurrent: ${prefs().fetchMoreMode === "auto" ? "Auto" : "Manual"}\nCycle with j/k; Enter to apply.`,
|
`How the Feed and per-show episode lists load older episodes.\nManual: a "[Fetch More]" button at the bottom of the list.\nAuto: fetches automatically when reaching the bottom.\nType: select\nDefault: auto\nCurrent: ${prefs().fetchMoreMode === "auto" ? "Auto" : "Manual"}\nCycle with j/k; Enter to apply.`,
|
||||||
cycle: (dir) => {
|
cycle: (dir) => {
|
||||||
const modes: Array<"manual" | "auto"> = ["manual", "auto"];
|
const modes: Array<"manual" | "auto"> = ["manual", "auto"];
|
||||||
const idx = modes.indexOf(prefs().fetchMoreMode ?? "manual");
|
const idx = modes.indexOf(prefs().fetchMoreMode ?? "auto");
|
||||||
const next = modes[(idx + dir + modes.length) % modes.length];
|
const next = modes[(idx + dir + modes.length) % modes.length];
|
||||||
app.updatePreferences({ fetchMoreMode: next });
|
app.updatePreferences({ fetchMoreMode: next });
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ const defaultPreferences: UserPreferences = {
|
|||||||
autoDownloadScope: "all",
|
autoDownloadScope: "all",
|
||||||
autoDownloadWhitelist: [],
|
autoDownloadWhitelist: [],
|
||||||
autoJumpToPlayer: true,
|
autoJumpToPlayer: true,
|
||||||
fetchMoreMode: "manual",
|
fetchMoreMode: "auto",
|
||||||
refreshIntervalMinutes: 30,
|
refreshIntervalMinutes: 30,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -55,6 +55,9 @@ const FRAME_INTERVAL = 33;
|
|||||||
/** Number of PCM samples to read per frame (512 is a good FFT window) */
|
/** Number of PCM samples to read per frame (512 is a good FFT window) */
|
||||||
const SAMPLES_PER_FRAME = 512;
|
const SAMPLES_PER_FRAME = 512;
|
||||||
|
|
||||||
|
/** Timer handle as returned by setTimeout/setInterval in this runtime. */
|
||||||
|
type TimerHandle = ReturnType<typeof setTimeout>;
|
||||||
|
|
||||||
// ── Types ────────────────────────────────────────────────────────────────
|
// ── Types ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface VisualizerStore {
|
export interface VisualizerStore {
|
||||||
@@ -96,9 +99,9 @@ function createVisualizerStore(): VisualizerStore {
|
|||||||
// pause/resume (segments survive; only the ffmpeg pass is killed) and
|
// pause/resume (segments survive; only the ffmpeg pass is killed) and
|
||||||
// dropped only on episode change, stop, disable, or unload.
|
// dropped only on episode change, stop, disable, or unload.
|
||||||
let pcm: EpisodePcmCache | null = null;
|
let pcm: EpisodePcmCache | null = null;
|
||||||
let frameTimer: ReturnType<typeof setInterval> | null = null;
|
let frameTimer: TimerHandle | null = null;
|
||||||
let sampleBuffer: Float64Array | null = null;
|
let sampleBuffer: Float64Array | null = null;
|
||||||
let unloadTimer: ReturnType<typeof setTimeout> | null = null;
|
let unloadTimer: TimerHandle | null = null;
|
||||||
|
|
||||||
// What the running pipeline was started with — lets the playback effect
|
// What the running pipeline was started with — lets the playback effect
|
||||||
// tell "nothing changed, stay warm" from "must restart".
|
// tell "nothing changed, stay warm" from "must restart".
|
||||||
@@ -209,6 +212,8 @@ function createVisualizerStore(): VisualizerStore {
|
|||||||
clearInterval(frameTimer);
|
clearInterval(frameTimer);
|
||||||
frameTimer = null;
|
frameTimer = null;
|
||||||
}
|
}
|
||||||
|
clearTimeout(seekDecodeTimer);
|
||||||
|
seekDecodeTimer = undefined;
|
||||||
if (pcm) {
|
if (pcm) {
|
||||||
pcm.stop();
|
pcm.stop();
|
||||||
// Keep the (now cache-less, url-tagged) object: a re-start of the
|
// Keep the (now cache-less, url-tagged) object: a re-start of the
|
||||||
@@ -233,6 +238,10 @@ function createVisualizerStore(): VisualizerStore {
|
|||||||
clearInterval(frameTimer);
|
clearInterval(frameTimer);
|
||||||
frameTimer = null;
|
frameTimer = null;
|
||||||
}
|
}
|
||||||
|
// Cancel any debounced seek-decode: it would restart ffmpeg while
|
||||||
|
// paused, defeating the "no background CPU while paused" contract.
|
||||||
|
clearTimeout(seekDecodeTimer);
|
||||||
|
seekDecodeTimer = undefined;
|
||||||
if (pcm) pcm.pauseDecode();
|
if (pcm) pcm.pauseDecode();
|
||||||
// Cava plan + sampleBuffer stay alive — cheap to reuse on resume.
|
// Cava plan + sampleBuffer stay alive — cheap to reuse on resume.
|
||||||
// Clear the loading spinner: if the pipeline never produced bars
|
// Clear the loading spinner: if the pipeline never produced bars
|
||||||
@@ -375,6 +384,7 @@ function createVisualizerStore(): VisualizerStore {
|
|||||||
// while the last frame holds.
|
// while the last frame holds.
|
||||||
|
|
||||||
let lastSyncPosition = 0;
|
let lastSyncPosition = 0;
|
||||||
|
let seekDecodeTimer: TimerHandle | undefined;
|
||||||
createEffect(
|
createEffect(
|
||||||
on(audioPlaybackSignals.position, (pos) => {
|
on(audioPlaybackSignals.position, (pos) => {
|
||||||
if (!audioPlaybackSignals.isPlaying() || !pcm) {
|
if (!audioPlaybackSignals.isPlaying() || !pcm) {
|
||||||
@@ -386,7 +396,16 @@ function createVisualizerStore(): VisualizerStore {
|
|||||||
lastSyncPosition = pos;
|
lastSyncPosition = pos;
|
||||||
|
|
||||||
if (delta > 2) {
|
if (delta > 2) {
|
||||||
pcm.ensureDecodeAround(pos);
|
// Debounce: holding the seek key fires a jump per poll tick —
|
||||||
|
// without debounce each one restarts ffmpeg, spamming network
|
||||||
|
// reconnects against the stream's server. Wait for the user to
|
||||||
|
// settle, then decode at the final position.
|
||||||
|
clearTimeout(seekDecodeTimer);
|
||||||
|
const target = pcm; // capture for the timer
|
||||||
|
seekDecodeTimer = setTimeout(() => {
|
||||||
|
seekDecodeTimer = undefined;
|
||||||
|
target.ensureDecodeAround(untrack(audioPlaybackSignals.position));
|
||||||
|
}, 400);
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ export type AppSettings = {
|
|||||||
visualizer: VisualizerSettings;
|
visualizer: VisualizerSettings;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** How the Feed and per-show episode lists load older episodes (default: manual "[Fetch More]"). */
|
/** How the Feed and per-show episode lists load older episodes (default: auto). */
|
||||||
export type FetchMoreMode = "manual" | "auto";
|
export type FetchMoreMode = "manual" | "auto";
|
||||||
|
|
||||||
/** Which shows the auto-download setting applies to (default: all). */
|
/** Which shows the auto-download setting applies to (default: all). */
|
||||||
@@ -107,7 +107,7 @@ export type UserPreferences = {
|
|||||||
autoDownloadWhitelist: string[];
|
autoDownloadWhitelist: string[];
|
||||||
/** Jump to the Player view automatically when playback starts (default: true) */
|
/** Jump to the Player view automatically when playback starts (default: true) */
|
||||||
autoJumpToPlayer: boolean;
|
autoJumpToPlayer: boolean;
|
||||||
/** Load older episodes from the Feed list: manual button or automatic at the bottom (default: manual). */
|
/** Load older episodes from the Feed list: manual button or automatic at the bottom (default: auto). */
|
||||||
fetchMoreMode: FetchMoreMode;
|
fetchMoreMode: FetchMoreMode;
|
||||||
/** Minutes between automatic background feed refreshes (default: 30). */
|
/** Minutes between automatic background feed refreshes (default: 30). */
|
||||||
refreshIntervalMinutes: number;
|
refreshIntervalMinutes: number;
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ const defaultPreferences: UserPreferences = {
|
|||||||
autoDownloadScope: "all",
|
autoDownloadScope: "all",
|
||||||
autoDownloadWhitelist: [],
|
autoDownloadWhitelist: [],
|
||||||
autoJumpToPlayer: true,
|
autoJumpToPlayer: true,
|
||||||
fetchMoreMode: "manual",
|
fetchMoreMode: "auto",
|
||||||
refreshIntervalMinutes: 30,
|
refreshIntervalMinutes: 30,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,14 @@ const BYTES_PER_SAMPLE = 2; // s16le
|
|||||||
/** Initial segment capacity: 4 Mi samples ≈ 190 s of audio (8 MB). */
|
/** Initial segment capacity: 4 Mi samples ≈ 190 s of audio (8 MB). */
|
||||||
const INITIAL_CAPACITY_SAMPLES = 4 * 1024 * 1024;
|
const INITIAL_CAPACITY_SAMPLES = 4 * 1024 * 1024;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gap (seconds) a running decode pass may close on its own before a restart
|
||||||
|
* at the seek target is cheaper than waiting: at 4x pacing, 15s of undecoded
|
||||||
|
* audio closes in ~4s — about the cost of a network reconnect + range
|
||||||
|
* request for a fresh ffmpeg pass. Beyond the gap, restart at the target.
|
||||||
|
*/
|
||||||
|
const CLOSE_IN_PLACE_GAP_SEC = 15;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Monotonically increasing generation counter.
|
* Monotonically increasing generation counter.
|
||||||
* Each startDecode() increments this; the read loop checks it to know
|
* Each startDecode() increments this; the read loop checks it to know
|
||||||
@@ -72,8 +80,8 @@ export class EpisodePcmCache {
|
|||||||
private segments: Segment[] = [];
|
private segments: Segment[] = [];
|
||||||
private generation = 0;
|
private generation = 0;
|
||||||
private _decoding = false;
|
private _decoding = false;
|
||||||
/** Base offset (playback seconds) of the running decode pass; null when idle. */
|
/** The running pass's segment (base + frontier); null when idle. */
|
||||||
private activeBaseSec: number | null = null;
|
private activeSegment: Segment | null = null;
|
||||||
readonly url: string;
|
readonly url: string;
|
||||||
readonly sampleRate: number;
|
readonly sampleRate: number;
|
||||||
|
|
||||||
@@ -87,6 +95,13 @@ export class EpisodePcmCache {
|
|||||||
return this._decoding;
|
return this._decoding;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Base (playback seconds) of the running decode pass; null when idle. */
|
||||||
|
get activeDecodeBaseSec(): number | null {
|
||||||
|
return this._decoding && this.activeSegment
|
||||||
|
? this.activeSegment.baseSec
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
/** End (playback seconds) of the furthest-decoded segment. */
|
/** End (playback seconds) of the furthest-decoded segment. */
|
||||||
get coverageEndSec(): number {
|
get coverageEndSec(): number {
|
||||||
let end = 0;
|
let end = 0;
|
||||||
@@ -184,14 +199,14 @@ export class EpisodePcmCache {
|
|||||||
stdin: "ignore",
|
stdin: "ignore",
|
||||||
});
|
});
|
||||||
this._decoding = true;
|
this._decoding = true;
|
||||||
this.activeBaseSec = segment.baseSec;
|
this.activeSegment = segment;
|
||||||
this.readLoop(myGeneration, segment);
|
this.readLoop(myGeneration, segment);
|
||||||
|
|
||||||
this.proc.exited
|
this.proc.exited
|
||||||
.then((code) => {
|
.then((code) => {
|
||||||
if (this.generation === myGeneration) {
|
if (this.generation === myGeneration) {
|
||||||
this._decoding = false;
|
this._decoding = false;
|
||||||
this.activeBaseSec = null;
|
this.activeSegment = null;
|
||||||
// Exit 0 == decoded to stream EOF.
|
// Exit 0 == decoded to stream EOF.
|
||||||
if (code === 0) segment.finished = true;
|
if (code === 0) segment.finished = true;
|
||||||
}
|
}
|
||||||
@@ -199,7 +214,7 @@ export class EpisodePcmCache {
|
|||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (this.generation === myGeneration) {
|
if (this.generation === myGeneration) {
|
||||||
this._decoding = false;
|
this._decoding = false;
|
||||||
this.activeBaseSec = null;
|
this.activeSegment = null;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -223,23 +238,30 @@ export class EpisodePcmCache {
|
|||||||
* new segment at `sec` (seek into a hole / resume past cached audio).
|
* new segment at `sec` (seek into a hole / resume past cached audio).
|
||||||
*/
|
*/
|
||||||
ensureDecodeAround(sec: number): void {
|
ensureDecodeAround(sec: number): void {
|
||||||
if (this._decoding) {
|
// Data already on hand: nothing needed here; only keep the tail
|
||||||
// A decode pass fills monotonically FORWARD from its base. Only a
|
// filling if the decode is idle and the episode is unfinished.
|
||||||
// target at/after the active base is eventually covered by it —
|
|
||||||
// a target BEHIND the base (seek into an undecoded hole ahead of
|
|
||||||
// the active pass) never is: kill the pass and restart at sec.
|
|
||||||
if (this.activeBaseSec !== null && sec >= this.activeBaseSec) return;
|
|
||||||
this.startDecode(Math.max(0, sec));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (this.covers(sec)) {
|
if (this.covers(sec)) {
|
||||||
// Covered here: continue the tail so the cache keeps filling
|
if (this._decoding || this.decodeFinished) return;
|
||||||
// past the position (unless the whole episode is decoded).
|
|
||||||
if (this.decodeFinished) return;
|
|
||||||
this.startDecode(this.coverageEndSec > sec ? this.coverageEndSec : sec);
|
this.startDecode(this.coverageEndSec > sec ? this.coverageEndSec : sec);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Seek into an undecoded region: start a fresh segment there.
|
|
||||||
|
if (this._decoding && this.activeSegment !== null) {
|
||||||
|
// A decode pass fills monotonically FORWARD from its base. Targets
|
||||||
|
// behind the base are unreachable — restart at the target.
|
||||||
|
if (sec < this.activeSegment.baseSec) {
|
||||||
|
this.startDecode(Math.max(0, sec));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Target past the pass's frontier: a SMALL gap closes on its own
|
||||||
|
// (4x pacing covers 15s in ~4s — about what a cold restart costs
|
||||||
|
// to reconnect + range-request a network stream), but a FAR-FORWARD
|
||||||
|
// seek would otherwise mean minutes of frozen bars while the pass
|
||||||
|
// chews through the skipped region. Restart at the target.
|
||||||
|
const frontier =
|
||||||
|
this.activeSegment.baseSec + this.activeSegment.written / this.sampleRate;
|
||||||
|
if (sec - frontier <= CLOSE_IN_PLACE_GAP_SEC) return;
|
||||||
|
}
|
||||||
this.startDecode(Math.max(0, sec));
|
this.startDecode(Math.max(0, sec));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -275,7 +297,7 @@ export class EpisodePcmCache {
|
|||||||
pauseDecode(): void {
|
pauseDecode(): void {
|
||||||
this.generation = ++globalGeneration;
|
this.generation = ++globalGeneration;
|
||||||
this._decoding = false;
|
this._decoding = false;
|
||||||
this.activeBaseSec = null;
|
this.activeSegment = null;
|
||||||
this.killProcess();
|
this.killProcess();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
import { platform } from "os";
|
import { platform } from "os";
|
||||||
import { existsSync, unlinkSync } from "fs";
|
import { existsSync, unlinkSync } from "fs";
|
||||||
import { tmpdir } from "os";
|
import { tmpdir } from "os";
|
||||||
import { dirname, join } from "path";
|
import { join } from "path";
|
||||||
import type { Socket, Subprocess } from "bun";
|
import type { Socket, Subprocess } from "bun";
|
||||||
|
|
||||||
// ── Types ────────────────────────────────────────────────────────────
|
// ── Types ────────────────────────────────────────────────────────────
|
||||||
@@ -115,53 +115,13 @@ function mpvSocketPath(): string {
|
|||||||
// Per-instance, not just per-pid: tests (and backend switching) create
|
// Per-instance, not just per-pid: tests (and backend switching) create
|
||||||
// several MpvBackend objects in ONE bun process — a pid-only path makes
|
// several MpvBackend objects in ONE bun process — a pid-only path makes
|
||||||
// every daemon bind the same socket, so later daemons unlink the path
|
// every daemon bind the same socket, so later daemons unlink the path
|
||||||
// out from under earlier ones and IPC cross-talks between backends.
|
// out from under each other.
|
||||||
return join(
|
return join(
|
||||||
tmpdir(),
|
tmpdir(),
|
||||||
`podtui-mpv-${process.pid}-${mpvInstance++}.sock`,
|
`podtui-mpv-${process.pid}-${mpvInstance++}.sock`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* mpv executable to use. Prefers a sibling `mpv` inside the app bundle
|
|
||||||
* (macOS PodTui.app/Contents/MacOS/mpv): running mpv from inside the bundle
|
|
||||||
* makes macOS attribute its Now Playing session to PodTui — source-app icon
|
|
||||||
* and name in Control Center — instead of a blank placeholder for an
|
|
||||||
* unbundled binary.
|
|
||||||
*
|
|
||||||
* The bundled copy is verified to actually launch: it links against brew's
|
|
||||||
* dylibs by absolute path, and a Homebrew ffmpeg major upgrade can break it
|
|
||||||
* (dylib gone → immediate non-zero exit). If the bundled binary can't run,
|
|
||||||
* fall back to PATH mpv so audio keeps working — the icon degrades to blank
|
|
||||||
* rather than playback dying. Probed once per process.
|
|
||||||
*/
|
|
||||||
let resolvedMpv: string | null | undefined; // undefined = not yet probed
|
|
||||||
|
|
||||||
function mpvLaunches(binary: string): boolean {
|
|
||||||
try {
|
|
||||||
const proc = Bun.spawnSync([binary, "--version"], { timeout: 3000 });
|
|
||||||
return proc.exitCode === 0;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveMpvBinary(): string | null {
|
|
||||||
if (resolvedMpv !== undefined) return resolvedMpv;
|
|
||||||
let resolved: string | null = null;
|
|
||||||
try {
|
|
||||||
const bundled = join(dirname(process.execPath), "mpv");
|
|
||||||
if (existsSync(bundled) && mpvLaunches(bundled)) {
|
|
||||||
resolved = bundled;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
/* process.execPath unusable — fall through to PATH */
|
|
||||||
}
|
|
||||||
if (!resolved) resolved = which("mpv");
|
|
||||||
resolvedMpv = resolved;
|
|
||||||
return resolved;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── mpv JSON IPC connection ─────────────────────────────────────────
|
// ── mpv JSON IPC connection ─────────────────────────────────────────
|
||||||
//
|
//
|
||||||
// One persistent Unix-socket connection to the resident mpv daemon. Lines
|
// One persistent Unix-socket connection to the resident mpv daemon. Lines
|
||||||
@@ -377,7 +337,7 @@ export class MpvBackend implements AudioBackend {
|
|||||||
|
|
||||||
this.proc = Bun.spawn(
|
this.proc = Bun.spawn(
|
||||||
[
|
[
|
||||||
resolveMpvBinary() ?? "mpv",
|
"mpv",
|
||||||
"--no-video",
|
"--no-video",
|
||||||
"--no-terminal",
|
"--no-terminal",
|
||||||
"--really-quiet",
|
"--really-quiet",
|
||||||
@@ -799,7 +759,7 @@ export interface DetectedPlayer {
|
|||||||
export function detectPlayers(): DetectedPlayer[] {
|
export function detectPlayers(): DetectedPlayer[] {
|
||||||
const players: DetectedPlayer[] = [];
|
const players: DetectedPlayer[] = [];
|
||||||
|
|
||||||
const mpvPath = resolveMpvBinary();
|
const mpvPath = which("mpv");
|
||||||
if (mpvPath) {
|
if (mpvPath) {
|
||||||
players.push({
|
players.push({
|
||||||
name: "mpv",
|
name: "mpv",
|
||||||
@@ -833,13 +793,13 @@ export function createAudioBackend(preferred?: BackendName): AudioBackend {
|
|||||||
if (backend) return backend;
|
if (backend) return backend;
|
||||||
}
|
}
|
||||||
|
|
||||||
return resolveMpvBinary() ? new MpvBackend() : new NoopBackend();
|
return which("mpv") ? new MpvBackend() : new NoopBackend();
|
||||||
}
|
}
|
||||||
|
|
||||||
function createBackendByName(name: BackendName): AudioBackend | null {
|
function createBackendByName(name: BackendName): AudioBackend | null {
|
||||||
switch (name) {
|
switch (name) {
|
||||||
case "mpv":
|
case "mpv":
|
||||||
return resolveMpvBinary() ? new MpvBackend() : null;
|
return which("mpv") ? new MpvBackend() : null;
|
||||||
case "none":
|
case "none":
|
||||||
return new NoopBackend();
|
return new NoopBackend();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
* always overwrite — no backup files are created.
|
* always overwrite — no backup files are created.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { mkdir } from "fs/promises";
|
||||||
import { ensureConfigDir, getConfigDir, getConfigFilePath } from "./config-dir";
|
import { ensureConfigDir, getConfigDir, getConfigFilePath } from "./config-dir";
|
||||||
import type {
|
import type {
|
||||||
AppSettings,
|
AppSettings,
|
||||||
@@ -58,15 +59,34 @@ let writeChain: Promise<void> = Promise.resolve();
|
|||||||
|
|
||||||
/** Update sections of config.json (read-modify-write, serialized, overwrite). */
|
/** Update sections of config.json (read-modify-write, serialized, overwrite). */
|
||||||
export function updateConfig(patch: Partial<PodTuiConfig>): void {
|
export function updateConfig(patch: Partial<PodTuiConfig>): void {
|
||||||
|
// Capture the target path AND the patch data eagerly, at call time:
|
||||||
|
// the write chain defers execution, and both the config dir (tests
|
||||||
|
// re-point XDG_CONFIG_HOME between ops) and the state object (stores
|
||||||
|
// mutate in place) move under a pending write. Without the capture, a
|
||||||
|
// queued save writes the LATEST state into whatever directory is
|
||||||
|
// current when the chain drains — a cross-directory misdelivery that
|
||||||
|
// was the source of a flaky "enabled:false survives reload" test.
|
||||||
|
const configPath = getConfigFilePath(CONFIG_FILE);
|
||||||
|
const configDir = getConfigDir();
|
||||||
|
const snapshot = JSON.parse(JSON.stringify(patch)) as Partial<PodTuiConfig>;
|
||||||
writeChain = writeChain.then(async () => {
|
writeChain = writeChain.then(async () => {
|
||||||
try {
|
try {
|
||||||
await ensureConfigDir();
|
await migrateOnce();
|
||||||
const current = await loadConfig();
|
await mkdir(configDir, { recursive: true });
|
||||||
const next = { ...current, ...patch };
|
let current: PodTuiConfig = {};
|
||||||
await Bun.write(
|
try {
|
||||||
getConfigFilePath(CONFIG_FILE),
|
const file = Bun.file(configPath);
|
||||||
JSON.stringify(next, null, 2),
|
if (await file.exists()) {
|
||||||
);
|
const raw = await file.json();
|
||||||
|
if (raw && typeof raw === "object") {
|
||||||
|
current = raw as PodTuiConfig;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* unreadable existing config — treat as empty */
|
||||||
|
}
|
||||||
|
const next = { ...current, ...snapshot };
|
||||||
|
await Bun.write(configPath, JSON.stringify(next, null, 2));
|
||||||
} catch {
|
} catch {
|
||||||
// Fire-and-forget persistence — silently ignore write errors.
|
// Fire-and-forget persistence — silently ignore write errors.
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -97,6 +97,55 @@ function tmpWav(): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const hasFfmpeg = !!Bun.which("ffmpeg");
|
const hasFfmpeg = !!Bun.which("ffmpeg");
|
||||||
|
|
||||||
|
test.skipIf(!hasFfmpeg)(
|
||||||
|
"far-forward seek into undecoded territory restarts decode AT the target (bars recover in seconds, not minutes)",
|
||||||
|
async () => {
|
||||||
|
const wav = tmpWav();
|
||||||
|
writeSineWav(wav, 60);
|
||||||
|
const cache = new EpisodePcmCache({ url: wav });
|
||||||
|
try {
|
||||||
|
cache.startDecode(0);
|
||||||
|
await waitForCoverage(cache, 1);
|
||||||
|
|
||||||
|
// Skipping 45s ahead while the pass still crawls at 4x must restart
|
||||||
|
// the segment at the target — waiting for the frontier to chew
|
||||||
|
// through the skipped region is minutes of frozen bars.
|
||||||
|
cache.ensureDecodeAround(45);
|
||||||
|
expect(cache.decoding).toBe(true);
|
||||||
|
expect(cache.activeDecodeBaseSec).toBe(45);
|
||||||
|
await waitForCoverage(cache, 45.1);
|
||||||
|
} finally {
|
||||||
|
cache.stop();
|
||||||
|
await Bun.$`rm -f ${wav}`.quiet();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ timeout: 20000 },
|
||||||
|
);
|
||||||
|
|
||||||
|
test.skipIf(!hasFfmpeg)(
|
||||||
|
"small forward gap closes in place — no needless reconnect",
|
||||||
|
async () => {
|
||||||
|
const wav = tmpWav();
|
||||||
|
writeSineWav(wav, 60);
|
||||||
|
const cache = new EpisodePcmCache({ url: wav });
|
||||||
|
try {
|
||||||
|
cache.startDecode(0);
|
||||||
|
await waitForCoverage(cache, 2);
|
||||||
|
|
||||||
|
// ~5s past the running frontier: at 4x pacing this closes in ~1.5s,
|
||||||
|
// cheaper than a reconnect — the pass must NOT restart.
|
||||||
|
const target = cache.coverageEndSec + 5;
|
||||||
|
cache.ensureDecodeAround(target);
|
||||||
|
expect(cache.activeDecodeBaseSec).toBe(0);
|
||||||
|
await waitForCoverage(cache, target);
|
||||||
|
} finally {
|
||||||
|
cache.stop();
|
||||||
|
await Bun.$`rm -f ${wav}`.quiet();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ timeout: 20000 },
|
||||||
|
);
|
||||||
const FIVE_SEC_BASE = 5 * SAMPLE_RATE; // decode offset for position-mapping tests
|
const FIVE_SEC_BASE = 5 * SAMPLE_RATE; // decode offset for position-mapping tests
|
||||||
|
|
||||||
test.skipIf(!hasFfmpeg)(
|
test.skipIf(!hasFfmpeg)(
|
||||||
|
|||||||
Reference in New Issue
Block a user