Compare commits
4 Commits
64d8b40e61
...
v0.2.0
| Author | SHA1 | Date | |
|---|---|---|---|
| de01cedee0 | |||
| 2730fa3cae | |||
| 91a831c5f9 | |||
| 52e9ae0ab7 |
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": 1,
|
||||
"version": 2,
|
||||
"podcasts": [
|
||||
{
|
||||
"id": "discover-daily",
|
||||
@@ -29,7 +29,7 @@
|
||||
"id": "discover-code-switch",
|
||||
"title": "Code Switch",
|
||||
"description": "Race. In your face. A podcast from NPR that fearlessly explores how race impacts every part of society — from politics to pop culture.",
|
||||
"feedUrl": "https://feeds.npr.org/510352/podcast.xml",
|
||||
"feedUrl": "https://feeds.npr.org/510312/podcast.xml",
|
||||
"author": "NPR",
|
||||
"categories": ["News", "Culture", "Politics"]
|
||||
},
|
||||
@@ -149,8 +149,8 @@
|
||||
"id": "discover-all-in",
|
||||
"title": "All-In Podcast",
|
||||
"description": "Four tech industry veterans share their unfiltered perspectives on technology, economics, politics, and culture. Insightful, opinionated, and occasionally controversial.",
|
||||
"feedUrl": "https://feeds.transistor.fm/all-in",
|
||||
"author": "Chamath, Jason, Sacks & Friedberg",
|
||||
"feedUrl": "https://allinchamathjason.libsyn.com/rss",
|
||||
"author": "Chamath Palihapitiya, Jason Calacanis, David Sacks & David Friedberg",
|
||||
"categories": ["Business", "Technology", "Politics"]
|
||||
},
|
||||
{
|
||||
@@ -253,7 +253,7 @@
|
||||
"id": "discover-philosophize-this",
|
||||
"title": "Philosophize This!",
|
||||
"description": "Stephen West walks through the entire history of philosophy chronologically, from the pre-Socratics to contemporary thinkers. Making profound ideas accessible without dumbing them down.",
|
||||
"feedUrl": "https://feed.podbean.com/philosophizethis/feed.xml",
|
||||
"feedUrl": "https://philosophizethis.libsyn.com/rss",
|
||||
"author": "Stephen West",
|
||||
"categories": ["Philosophy", "Education"]
|
||||
},
|
||||
@@ -261,7 +261,7 @@
|
||||
"id": "discover-very-bad-wizards",
|
||||
"title": "Very Bad Wizards",
|
||||
"description": "A philosopher (Tamler Sommers) and a psychologist (David Pizarro) discuss human nature, ethics, free will, and whatever movie they just watched. Irreverent, insightful, and intellectually honest.",
|
||||
"feedUrl": "https://feed.podbean.com/verybadwizards/feed.xml",
|
||||
"feedUrl": "https://feeds.libsyn.com/474285/rss",
|
||||
"author": "Tamler Sommers & David Pizarro",
|
||||
"categories": ["Philosophy", "Science"]
|
||||
},
|
||||
@@ -389,8 +389,8 @@
|
||||
"id": "discover-sysk",
|
||||
"title": "Stuff You Should Know",
|
||||
"description": "If you've ever wanted to know about champagne, satanism, the Stonewall Uprising, chaos theory, LSD, El Nino, true crime or Roswell — Josh and Chuck have got you covered.",
|
||||
"feedUrl": "https://rss.art19.com/stuff-you-should-know",
|
||||
"author": "iHeartRadio",
|
||||
"feedUrl": "https://www.omnycontent.com/d/playlist/e73c998e-6e60-432f-8610-ae210140c5b1/A91018A4-EA4F-4130-BF55-AE270180C327/44710ECC-10BB-48D1-93C7-AE270180C33E/podcast.rss",
|
||||
"author": "iHeartPodcasts (Josh Clark & Chuck Bryant)",
|
||||
"categories": ["Education", "Comedy"]
|
||||
},
|
||||
{
|
||||
|
||||
240
scripts/release-tag.sh
Executable file
240
scripts/release-tag.sh
Executable file
@@ -0,0 +1,240 @@
|
||||
#!/bin/bash
|
||||
|
||||
# release-tag.sh — PodTui version bump, commit, tag, and push.
|
||||
#
|
||||
# Mirrors the release flow from FlexLove's scripts/make-tag.sh, adapted for
|
||||
# PodTui's single version source (src/index.tsx) and dual remotes (gh, gt).
|
||||
#
|
||||
# Usage:
|
||||
# scripts/release-tag.sh interactive release
|
||||
# scripts/release-tag.sh --dry-run plan the bump/tag/pushes without doing
|
||||
#
|
||||
# Pushing a v* tag to the `gh` remote triggers .github/workflows/release.yml
|
||||
# (4-platform tarball builds) — the release and the Homebrew tap update then
|
||||
# happen automatically and need no further local action.
|
||||
|
||||
set -e
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
CYAN='\033[0;36m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
DRY_RUN=0
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--dry-run | -n) DRY_RUN=1 ;;
|
||||
--help | -h)
|
||||
echo "Usage: scripts/release-tag.sh [--dry-run]"
|
||||
echo " --dry-run, -n show the plan without committing, tagging, or pushing"
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo -e "${RED}Unknown option: ${arg}${NC}" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ ! -d .git ] && [ ! -f .git ]; then
|
||||
echo -e "${RED}Error: Not in a git repository${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! git diff-index --quiet HEAD --; then
|
||||
echo -e "${YELLOW}You have uncommitted changes:${NC}"
|
||||
git status --short
|
||||
echo ""
|
||||
read -p "Continue anyway? (y/n) " -n 1 -r
|
||||
echo
|
||||
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||
echo -e "${RED}Aborted${NC}"
|
||||
exit 1
|
||||
fi
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# Current version from the latest tag; fall back to src/index.tsx.
|
||||
CURRENT_VERSION=$(git describe --tags --abbrev=0 2>/dev/null | sed 's/^v//')
|
||||
if [ -z "$CURRENT_VERSION" ]; then
|
||||
CURRENT_VERSION=$(grep -m 1 "^const VERSION" src/index.tsx | sed -E 's/.*"([0-9]+\.[0-9]+\.[0-9]+)".*/\1/')
|
||||
if [ -z "$CURRENT_VERSION" ]; then
|
||||
echo -e "${RED}Error: could not extract version from git tags or src/index.tsx${NC}"
|
||||
exit 1
|
||||
fi
|
||||
echo -e "${YELLOW}No tags found; using VERSION from src/index.tsx (${CURRENT_VERSION})${NC}"
|
||||
fi
|
||||
|
||||
echo -e "${CYAN}Current version:${NC} ${GREEN}v${CURRENT_VERSION}${NC}"
|
||||
echo ""
|
||||
|
||||
IFS='.' read -r MAJOR MINOR PATCH <<<"$CURRENT_VERSION"
|
||||
MAJOR=$(echo "$MAJOR" | sed 's/[^0-9].*//')
|
||||
MINOR=$(echo "$MINOR" | sed 's/[^0-9].*//')
|
||||
PATCH=$(echo "$PATCH" | sed 's/[^0-9].*//')
|
||||
|
||||
echo -e "${CYAN}Select version bump type:${NC}"
|
||||
echo " 1) Major (breaking changes) ${MAJOR}.${MINOR}.${PATCH} → $((MAJOR + 1)).0.0"
|
||||
echo " 2) Minor (new features) ${MAJOR}.${MINOR}.${PATCH} → ${MAJOR}.$((MINOR + 1)).0"
|
||||
echo " 3) Patch (bug fixes) ${MAJOR}.${MINOR}.${PATCH} → ${MAJOR}.${MINOR}.$((PATCH + 1))"
|
||||
echo " 4) Custom version"
|
||||
echo " 5) Cancel"
|
||||
echo ""
|
||||
read -p "Enter choice (1-5): " -n 1 -r CHOICE
|
||||
echo ""
|
||||
echo ""
|
||||
|
||||
case $CHOICE in
|
||||
1)
|
||||
NEW_VERSION="$((MAJOR + 1)).0.0"
|
||||
;;
|
||||
2)
|
||||
NEW_VERSION="${MAJOR}.$((MINOR + 1)).0"
|
||||
;;
|
||||
3)
|
||||
NEW_VERSION="${MAJOR}.${MINOR}.$((PATCH + 1))"
|
||||
;;
|
||||
4)
|
||||
read -p "Enter custom version (e.g., 1.0.0-beta): " -r NEW_VERSION
|
||||
;;
|
||||
5)
|
||||
echo -e "${RED}Cancelled${NC}"
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo -e "${RED}Invalid choice${NC}"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
# Version sanity check (tags are vMAJOR.MINOR.PATCH).
|
||||
if ! echo "$NEW_VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then
|
||||
echo -e "${RED}Error: ${NEW_VERSION} is not a valid X.Y.Z version (v tags only)${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo -e "${CYAN}New version:${NC} ${GREEN}v${NEW_VERSION}${NC}"
|
||||
echo ""
|
||||
echo -e "${YELLOW}This will:${NC}"
|
||||
echo " 1. Set src/index.tsx → VERSION = \"${NEW_VERSION}\""
|
||||
echo " 2. Commit the bump"
|
||||
echo " 3. Create annotated tag v${NEW_VERSION}"
|
||||
echo " 4. Push master and the tag to every remote"
|
||||
REMOTES=$(git remote)
|
||||
for r in $REMOTES; do
|
||||
echo " → $r"
|
||||
done
|
||||
echo ""
|
||||
echo -e "${YELLOW}Note: pushing the tag to ${BLUE}gh${YELLOW} triggers release.yml CI (4-platform"
|
||||
echo "binaries + GitHub Release) and the homebrew-podtui tap update.${NC}"
|
||||
echo ""
|
||||
read -p "Proceed? (y/n) " -n 1 -r
|
||||
echo ""
|
||||
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||
echo -e "${YELLOW}Aborted — no changes made${NC}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "$DRY_RUN" -eq 1 ]; then
|
||||
echo ""
|
||||
echo -e "${CYAN}[dry-run]${NC} would have:"
|
||||
echo " sed src/index.tsx: VERSION \"${CURRENT_VERSION}\" → \"${NEW_VERSION}\""
|
||||
echo " git commit -m \"bump VERSION to ${NEW_VERSION}\""
|
||||
echo " git tag -a v${NEW_VERSION} -m \"PodTUI v${NEW_VERSION}\""
|
||||
for r in $REMOTES; do echo " push $r master"; done
|
||||
for r in $REMOTES; do echo " push $r v${NEW_VERSION}"; done
|
||||
echo ""
|
||||
echo -e "${GREEN}Plan only — nothing written${NC}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── Apply the bump ───────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo -e "${CYAN}[1/4]${NC} Updating src/index.tsx..."
|
||||
sed -i.bak "s/const VERSION = \"[^\"]*\"/const VERSION = \"${NEW_VERSION}\"/" src/index.tsx
|
||||
rm -f src/index.tsx.bak
|
||||
echo -e "${GREEN}✓ src/index.tsx updated${NC}"
|
||||
|
||||
if git diff --quiet -- src/index.tsx; then
|
||||
if git rev-parse -q --verify "refs/tags/v${NEW_VERSION}" >/dev/null; then
|
||||
echo -e "${YELLOW}Already at ${NEW_VERSION} and tag v${NEW_VERSION} exists — nothing to release.${NC}"
|
||||
exit 0
|
||||
fi
|
||||
echo -e "${YELLOW}VERSION is already ${NEW_VERSION} (bump already committed).${NC}"
|
||||
echo -e "${YELLOW}Will skip the commit and just create the missing tag + push.${NC}"
|
||||
read -p "Tag v${NEW_VERSION} on current HEAD and push? (y/n) " -n 1 -r
|
||||
echo
|
||||
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||
echo -e "${YELLOW}Aborted — no changes made${NC}"
|
||||
exit 0
|
||||
fi
|
||||
else
|
||||
git add src/index.tsx
|
||||
echo -e "${GREEN}✓ staged${NC}"
|
||||
|
||||
echo -e "${CYAN}[2/4]${NC} Committing..."
|
||||
DEFAULT_COMMIT_MSG="bump VERSION to ${NEW_VERSION}"
|
||||
echo -e "Default commit message: ${CYAN}${DEFAULT_COMMIT_MSG}${NC}"
|
||||
read -p "Use default? (y/n) " -n 1 -r
|
||||
echo
|
||||
if [[ $REPLY =~ ^[Nn]$ ]]; then
|
||||
read -p "Enter commit message: " -r COMMIT_MSG
|
||||
else
|
||||
COMMIT_MSG="$DEFAULT_COMMIT_MSG"
|
||||
fi
|
||||
git commit -m "$COMMIT_MSG"
|
||||
echo -e "${GREEN}✓ committed: ${COMMIT_MSG}${NC}"
|
||||
fi
|
||||
|
||||
echo -e "${CYAN}[3/4]${NC} Tagging..."
|
||||
git tag -a "v${NEW_VERSION}" -m "PodTUI v${NEW_VERSION}"
|
||||
echo -e "${GREEN}✓ tagged v${NEW_VERSION}${NC}"
|
||||
echo ""
|
||||
|
||||
echo -e "${CYAN}[4/4]${NC} Pushing..."
|
||||
FAILED=""
|
||||
for r in $REMOTES; do
|
||||
if ! git push "$r" master; then
|
||||
FAILED="${FAILED}${r} (branch) "
|
||||
fi
|
||||
if ! git push "$r" tag "v${NEW_VERSION}"; then
|
||||
FAILED="${FAILED}${r} (tag) "
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
if [ -n "$FAILED" ]; then
|
||||
echo -e "${RED}═══════════════════════════════════════${NC}"
|
||||
echo -e "${RED}✗ Push failed for: ${FAILED}${NC}"
|
||||
echo -e "${RED}═══════════════════════════════════════${NC}"
|
||||
echo ""
|
||||
echo -e "${YELLOW}The commit and tag exist locally. To retry:${NC}"
|
||||
for r in $REMOTES; do
|
||||
echo " git push ${r} master"
|
||||
echo " git push ${r} v${NEW_VERSION}"
|
||||
done
|
||||
echo ""
|
||||
echo -e "${YELLOW}To undo:${NC}"
|
||||
echo " git tag -d v${NEW_VERSION}"
|
||||
echo " git reset --soft HEAD~1"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo -e "${GREEN}═══════════════════════════════════════${NC}"
|
||||
echo -e "${GREEN}✓ PodTui v${NEW_VERSION} released${NC}"
|
||||
echo -e "${GREEN}═══════════════════════════════════════${NC}"
|
||||
echo ""
|
||||
echo -e "${CYAN}Version:${NC} ${CURRENT_VERSION} → ${GREEN}${NEW_VERSION}${NC}"
|
||||
echo -e "${CYAN}Tag:${NC} v${NEW_VERSION}"
|
||||
echo ""
|
||||
echo -e "${BLUE}Next steps (automatic, nothing to do):${NC}"
|
||||
echo " 1. GitHub Action release.yml builds 4 tarballs and attaches them:"
|
||||
echo -e " ${CYAN}gh run watch \$(gh run list --limit 1 --json databaseId -q .[0].databaseId)${NC}"
|
||||
echo " 2. mikefreno/homebrew-podtui self-updates within the hour (Formula"
|
||||
echo " URLs + sha256s); brew upgrade podtui afterwards."
|
||||
@@ -436,16 +436,9 @@ async function main() {
|
||||
}
|
||||
if (newAction) {
|
||||
if (flags.audio && audioControls?.switchBackend) {
|
||||
// Re-detect: clear env so detection picks the best real backend.
|
||||
delete process.env.PODTUI_AUDIO_BACKEND;
|
||||
// Force (re)creation of a real backend; useAudio caches, switchBackend resets.
|
||||
delete process.env.PODTUI_AUDIO_BACKEND;
|
||||
await audioControls.switchBackend("mpv").catch(() => {});
|
||||
if (
|
||||
!audioControls.backendName() ||
|
||||
audioControls.backendName() === "none"
|
||||
) {
|
||||
await audioControls.switchBackend("afplay").catch(() => {});
|
||||
}
|
||||
}
|
||||
actions.push(newAction);
|
||||
saveActions(actions);
|
||||
|
||||
@@ -88,7 +88,7 @@ function ensureBackend(): AudioBackend {
|
||||
// ── Process-exit teardown ─────────────────────────────────────────────
|
||||
// `q` (the quit action) calls `process.exit(0)`, which bypasses Solid's
|
||||
// onCleanup — where `backend.dispose()` would otherwise kill the spawned
|
||||
// player (mpv/ffplay/afplay). Without this hook those child processes
|
||||
// player (mpv). Without this hook those child processes
|
||||
// survive the host and keep playing audio after the TUI has quit. The
|
||||
// `exit` event fires synchronously on `process.exit(N)`; the signal
|
||||
// handlers cover Ctrl-C / kill, which otherwise terminate without running
|
||||
|
||||
@@ -5,13 +5,13 @@
|
||||
* regardless of which component is focused. Uses the event bus to
|
||||
* decouple key detection from audio control logic.
|
||||
*
|
||||
* Keys are only handled when an episode is loaded (or for play/pause,
|
||||
* always). This prevents accidental volume/seek changes when there's
|
||||
* nothing playing.
|
||||
* Volume and speed are app-level settings — adjustable with or without
|
||||
* an episode loaded (they apply to the next playback and persist). Seek
|
||||
* is playback-dependent, so it still requires a loaded episode.
|
||||
*/
|
||||
|
||||
import { useKeyboard } from "@opentui/solid"
|
||||
import { emit } from "../utils/event-bus"
|
||||
import { useKeyboard } from "@opentui/solid";
|
||||
import { emit } from "../utils/event-bus";
|
||||
|
||||
export type MediaKeyAction =
|
||||
| "media.toggle"
|
||||
@@ -19,7 +19,7 @@ export type MediaKeyAction =
|
||||
| "media.volumeDown"
|
||||
| "media.seekForward"
|
||||
| "media.seekBackward"
|
||||
| "media.speedCycle"
|
||||
| "media.speedCycle";
|
||||
|
||||
/** Key-to-action mappings for multimedia controls */
|
||||
const MEDIA_KEY_MAP: Record<string, MediaKeyAction> = {
|
||||
@@ -33,15 +33,15 @@ const MEDIA_KEY_MAP: Record<string, MediaKeyAction> = {
|
||||
// bus approach — the audio hook only processes event-bus events, and
|
||||
// Player.tsx calls audio methods directly. We therefore guard with
|
||||
// a "playerFocused" flag passed via options.
|
||||
}
|
||||
};
|
||||
|
||||
export interface MultimediaKeysOptions {
|
||||
/** When true, skip handling (Player.tsx handles keys locally) */
|
||||
playerFocused?: () => boolean
|
||||
playerFocused?: () => boolean;
|
||||
/** When true, skip handling (text input has focus) */
|
||||
inputFocused?: () => boolean
|
||||
inputFocused?: () => boolean;
|
||||
/** Whether an episode is currently loaded */
|
||||
hasEpisode?: () => boolean
|
||||
hasEpisode?: () => boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -51,48 +51,45 @@ export interface MultimediaKeysOptions {
|
||||
export function useMultimediaKeys(options: MultimediaKeysOptions = {}) {
|
||||
useKeyboard((key) => {
|
||||
// Don't intercept when a text input owns the keyboard
|
||||
if (options.inputFocused?.()) return
|
||||
if (options.inputFocused?.()) return;
|
||||
|
||||
// Don't intercept when Player component handles its own keys
|
||||
if (options.playerFocused?.()) return
|
||||
if (options.playerFocused?.()) return;
|
||||
|
||||
// Ctrl/Meta combos are app-level shortcuts, not media keys
|
||||
if (key.ctrl || key.meta) return
|
||||
if (key.ctrl || key.meta) return;
|
||||
|
||||
switch (key.name) {
|
||||
case "space":
|
||||
// Toggle play/pause — always valid (may start a loaded episode)
|
||||
emit("media.toggle", {})
|
||||
break
|
||||
emit("media.toggle", {});
|
||||
break;
|
||||
|
||||
case "up":
|
||||
if (!options.hasEpisode?.()) return
|
||||
emit("media.volumeUp", {})
|
||||
break
|
||||
emit("media.volumeUp", {});
|
||||
break;
|
||||
|
||||
case "down":
|
||||
if (!options.hasEpisode?.()) return
|
||||
emit("media.volumeDown", {})
|
||||
break
|
||||
emit("media.volumeDown", {});
|
||||
break;
|
||||
|
||||
case "left":
|
||||
if (!options.hasEpisode?.()) return
|
||||
emit("media.seekBackward", {})
|
||||
break
|
||||
if (!options.hasEpisode?.()) return;
|
||||
emit("media.seekBackward", {});
|
||||
break;
|
||||
|
||||
case "right":
|
||||
if (!options.hasEpisode?.()) return
|
||||
emit("media.seekForward", {})
|
||||
break
|
||||
if (!options.hasEpisode?.()) return;
|
||||
emit("media.seekForward", {});
|
||||
break;
|
||||
|
||||
case "s":
|
||||
if (!options.hasEpisode?.()) return
|
||||
emit("media.speedCycle", {})
|
||||
break
|
||||
emit("media.speedCycle", {});
|
||||
break;
|
||||
|
||||
default:
|
||||
// Not a media key — do nothing
|
||||
break
|
||||
break;
|
||||
}
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const VERSION = "0.1.0";
|
||||
const VERSION = "0.2.0";
|
||||
|
||||
interface CliArgs {
|
||||
version: boolean;
|
||||
|
||||
@@ -16,9 +16,6 @@ type PlaybackControlsProps = {
|
||||
|
||||
const BACKEND_LABELS: Record<BackendName, string> = {
|
||||
mpv: "mpv",
|
||||
ffplay: "ffplay",
|
||||
afplay: "afplay",
|
||||
system: "system",
|
||||
none: "none",
|
||||
};
|
||||
|
||||
@@ -60,10 +57,12 @@ export function PlaybackControls(props: PlaybackControlsProps) {
|
||||
<box flexDirection="row" gap={1} marginLeft={2}>
|
||||
<text fg={theme.textMuted}>Vol</text>
|
||||
<text fg={theme.text}>{Math.round(props.volume * 100)}%</text>
|
||||
<text fg={theme.textMuted}>↑↓</text>
|
||||
</box>
|
||||
<box flexDirection="row" gap={1} marginLeft={2}>
|
||||
<text fg={theme.textMuted}>Speed</text>
|
||||
<text fg={theme.text}>{props.speed}x</text>
|
||||
<text fg={theme.textMuted}>s</text>
|
||||
</box>
|
||||
{props.backendName && props.backendName !== "none" && (
|
||||
<box flexDirection="row" gap={1} marginLeft={2}>
|
||||
|
||||
@@ -82,8 +82,9 @@ export function PlayerPage() {
|
||||
<RealtimeWaveform
|
||||
visualizerConfig={(() => {
|
||||
const viz = useAppStore().state().settings.visualizer;
|
||||
// bars is width-derived in RealtimeWaveform; pass only the
|
||||
// audio-processing params here.
|
||||
return {
|
||||
bars: viz.bars,
|
||||
noiseReduction: viz.noiseReduction,
|
||||
lowCutOff: viz.lowCutOff,
|
||||
highCutOff: viz.highCutOff,
|
||||
@@ -109,7 +110,7 @@ export function PlayerPage() {
|
||||
|
||||
<box height={1} />
|
||||
<text fg={muted()}>
|
||||
{"P play/pause N next B prev </ seek · h back"}
|
||||
{"P play/pause N next B prev ◀▶ seek h back"}
|
||||
</text>
|
||||
</box>
|
||||
);
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
*/
|
||||
|
||||
import { createSignal, createEffect, onCleanup, on, untrack } from "solid-js";
|
||||
import { useTerminalDimensions } from "@opentui/solid";
|
||||
import {
|
||||
loadCavaCore,
|
||||
type CavaCore,
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
import { AudioStreamReader } from "@/utils/audio-stream-reader";
|
||||
import { useAudio } from "@/hooks/useAudio";
|
||||
import { useTheme } from "@/context/ThemeContext";
|
||||
import { PANE_RATIO } from "@/utils/navigation";
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -51,17 +53,27 @@ export function RealtimeWaveform(props: RealtimeWaveformProps) {
|
||||
// Frequency bar values (0.0–1.0 per bar)
|
||||
const [barData, setBarData] = createSignal<number[]>([]);
|
||||
|
||||
// Track whether cavacore is available
|
||||
const [available, setAvailable] = createSignal(false);
|
||||
|
||||
let cava: CavaCore | null = null;
|
||||
let reader: AudioStreamReader | null = null;
|
||||
let frameTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let sampleBuffer: Float64Array | null = null;
|
||||
|
||||
// Bar count comes from the visualizer config (set in Settings); default 64.
|
||||
// Single source of truth used for cavacore init, rendering, and seek clicks.
|
||||
const numBars = () => props.visualizerConfig?.bars ?? 64;
|
||||
// Bar count scales with terminal width so the waveform fills its pane.
|
||||
// The player is a 2-pane row: current column = (current+preview) of
|
||||
// (parent+current+preview) of the terminal width. Subtract ~8 chars of
|
||||
// chrome (scrollbox border + box padding + waveform border + padding).
|
||||
// Falls back to 64 before the renderer reports a real size.
|
||||
const dimensions = useTerminalDimensions();
|
||||
const numBars = () => {
|
||||
const total = PANE_RATIO.parent + PANE_RATIO.current + PANE_RATIO.preview;
|
||||
const current = PANE_RATIO.current + PANE_RATIO.preview; // 2-pane grows current
|
||||
const width = dimensions().width;
|
||||
if (!width) return 64;
|
||||
return Math.max(
|
||||
8,
|
||||
Math.min(256, Math.floor((width * current) / total) - 8),
|
||||
);
|
||||
};
|
||||
|
||||
// ── Lifecycle: init cavacore once ──────────────────────────────────
|
||||
|
||||
@@ -70,11 +82,9 @@ export function RealtimeWaveform(props: RealtimeWaveformProps) {
|
||||
|
||||
cava = loadCavaCore();
|
||||
if (!cava) {
|
||||
setAvailable(false);
|
||||
return false;
|
||||
}
|
||||
|
||||
setAvailable(true);
|
||||
return true;
|
||||
};
|
||||
|
||||
@@ -85,7 +95,9 @@ export function RealtimeWaveform(props: RealtimeWaveformProps) {
|
||||
|
||||
if (!url || !initCava() || !cava) return;
|
||||
|
||||
// Initialize cavacore with current resolution + any overrides
|
||||
// Initialize cavacore with current resolution + any overrides.
|
||||
// bars is width-derived (see numBars); visualizerConfig supplies the
|
||||
// audio-processing params (noise reduction, cutoffs, etc.).
|
||||
const config: CavaCoreConfig = {
|
||||
bars: numBars(),
|
||||
sampleRate: 44100,
|
||||
@@ -140,7 +152,7 @@ export function RealtimeWaveform(props: RealtimeWaveformProps) {
|
||||
const output = cava.execute(input);
|
||||
|
||||
// Copy bar values to a new array for the signal
|
||||
setBarData(Array.from(output));
|
||||
setBarData(Array.from(output as Float64Array));
|
||||
};
|
||||
|
||||
createEffect(
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
/**
|
||||
* Cross-platform audio playback engine for PodTUI.
|
||||
* Audio playback engine for PodTUI.
|
||||
*
|
||||
* Backend priority:
|
||||
* 1. mpv — full IPC control (seek, volume, speed, position tracking)
|
||||
* 2. ffplay — basic control via process signals
|
||||
* 3. afplay — macOS built-in (no seek/speed, volume only)
|
||||
* 4. system — open/xdg-open/start (fire-and-forget, no control)
|
||||
*
|
||||
* All backends implement the AudioBackend interface so the Player
|
||||
* component doesn't need to care which one is active.
|
||||
* Single backend: mpv — full IPC control (seek, volume, speed, position
|
||||
* tracking), so speed/volume/seek changes apply instantly with no process
|
||||
* restart. When mpv isn't installed there is no fallback: the no-op backend
|
||||
* surfaces "No audio player found" honestly rather than degrading through
|
||||
* players that can't change speed/volume without restarting.
|
||||
*/
|
||||
|
||||
import { platform } from "os";
|
||||
@@ -18,7 +15,7 @@ import { join } from "path";
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────
|
||||
|
||||
export type BackendName = "mpv" | "ffplay" | "afplay" | "system" | "none";
|
||||
export type BackendName = "mpv" | "none";
|
||||
|
||||
export interface AudioState {
|
||||
playing: boolean;
|
||||
@@ -381,467 +378,6 @@ export class MpvBackend implements AudioBackend {
|
||||
}
|
||||
}
|
||||
|
||||
// ── ffplay Backend ───────────────────────────────────────────────────
|
||||
// ffplay has no IPC. We track duration from episode metadata and
|
||||
// position via elapsed wall-clock time. Seek requires restarting.
|
||||
|
||||
class FfplayBackend implements AudioBackend {
|
||||
readonly name: BackendName = "ffplay";
|
||||
private proc: ReturnType<typeof Bun.spawn> | null = null;
|
||||
private _playing = false;
|
||||
private _paused = false;
|
||||
private _position = 0;
|
||||
private _duration = 0;
|
||||
private _volume = 100;
|
||||
private _speed = 1;
|
||||
private _url = "";
|
||||
private startTime = 0;
|
||||
private pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
async play(url: string, opts?: PlayOptions): Promise<void> {
|
||||
await this.stop();
|
||||
|
||||
this._url = url;
|
||||
this._volume = Math.round((opts?.volume ?? 1) * 100);
|
||||
this._speed = opts?.speed ?? 1;
|
||||
this._position = opts?.startPosition ?? 0;
|
||||
|
||||
this.spawnProcess();
|
||||
}
|
||||
|
||||
private spawnProcess(): void {
|
||||
const args = [
|
||||
"ffplay",
|
||||
"-nodisp",
|
||||
"-autoexit",
|
||||
"-loglevel",
|
||||
"quiet",
|
||||
"-volume",
|
||||
String(this._volume),
|
||||
];
|
||||
|
||||
if (this._position > 0) {
|
||||
args.push("-ss", String(this._position));
|
||||
}
|
||||
|
||||
if (this._speed !== 1) {
|
||||
args.push("-af", `atempo=${this._speed}`);
|
||||
}
|
||||
|
||||
args.push("-i", this._url);
|
||||
|
||||
this.proc = Bun.spawn(args, {
|
||||
stdout: "ignore",
|
||||
stderr: "ignore",
|
||||
stdin: "ignore",
|
||||
});
|
||||
|
||||
this._playing = true;
|
||||
this._paused = false;
|
||||
this.startTime = Date.now();
|
||||
this.startPolling();
|
||||
|
||||
this.proc.exited
|
||||
.then(() => {
|
||||
this._playing = false;
|
||||
this.stopPolling();
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
private startPolling(): void {
|
||||
this.stopPolling();
|
||||
this.pollTimer = setInterval(() => {
|
||||
if (!this._playing) return;
|
||||
const elapsed = ((Date.now() - this.startTime) / 1000) * this._speed;
|
||||
this._position = this._position + elapsed;
|
||||
this.startTime = Date.now();
|
||||
}, 500);
|
||||
}
|
||||
|
||||
private stopPolling(): void {
|
||||
if (this.pollTimer) {
|
||||
clearInterval(this.pollTimer);
|
||||
this.pollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
async pause(): Promise<void> {
|
||||
if (this.proc) {
|
||||
const pid = (this.proc as unknown as { pid?: number } | null)?.pid;
|
||||
try {
|
||||
if (pid) process.kill(pid, "SIGSTOP");
|
||||
} catch {}
|
||||
this._paused = true;
|
||||
}
|
||||
this._playing = false;
|
||||
this.stopPolling();
|
||||
}
|
||||
|
||||
async resume(): Promise<void> {
|
||||
if (!this._url) return;
|
||||
if (this.proc && this._paused) {
|
||||
const pid = (this.proc as unknown as { pid?: number } | null)?.pid;
|
||||
try {
|
||||
if (pid) process.kill(pid, "SIGCONT");
|
||||
} catch {}
|
||||
this._paused = false;
|
||||
this._playing = true;
|
||||
this.startTime = Date.now();
|
||||
this.startPolling();
|
||||
return;
|
||||
}
|
||||
this.spawnProcess();
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
this.stopPolling();
|
||||
if (this.proc) {
|
||||
try {
|
||||
this.proc.kill();
|
||||
} catch {}
|
||||
this.proc = null;
|
||||
}
|
||||
this._playing = false;
|
||||
this._paused = false;
|
||||
this._position = 0;
|
||||
this._url = "";
|
||||
}
|
||||
|
||||
async seek(seconds: number): Promise<void> {
|
||||
this._position = seconds;
|
||||
if (this._playing && this._url) {
|
||||
// Restart at new position
|
||||
if (this.proc) {
|
||||
try {
|
||||
this.proc.kill();
|
||||
} catch {}
|
||||
this.proc = null;
|
||||
}
|
||||
this.spawnProcess();
|
||||
const pid = (this.proc as unknown as { pid?: number } | null)?.pid;
|
||||
if (this._paused && pid) {
|
||||
try {
|
||||
process.kill(pid, "SIGSTOP");
|
||||
} catch {}
|
||||
this._playing = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async setVolume(volume: number): Promise<void> {
|
||||
this._volume = Math.round(volume * 100);
|
||||
// ffplay has no runtime IPC; volume will apply on next play/resume.
|
||||
// Restart the process to apply immediately if currently playing.
|
||||
if (this._url && (this._playing || this._paused)) {
|
||||
this.stopPolling();
|
||||
if (this.proc) {
|
||||
try {
|
||||
this.proc.kill();
|
||||
} catch {}
|
||||
this.proc = null;
|
||||
}
|
||||
this.spawnProcess();
|
||||
const pid = (this.proc as unknown as { pid?: number } | null)?.pid;
|
||||
if (this._paused && pid) {
|
||||
try {
|
||||
process.kill(pid, "SIGSTOP");
|
||||
} catch {}
|
||||
this._playing = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async setSpeed(speed: number): Promise<void> {
|
||||
this._speed = speed;
|
||||
if (this._url && (this._playing || this._paused)) {
|
||||
this.stopPolling();
|
||||
if (this.proc) {
|
||||
try {
|
||||
this.proc.kill();
|
||||
} catch {}
|
||||
this.proc = null;
|
||||
}
|
||||
this.spawnProcess();
|
||||
const pid = (this.proc as unknown as { pid?: number } | null)?.pid;
|
||||
if (this._paused && pid) {
|
||||
try {
|
||||
process.kill(pid, "SIGSTOP");
|
||||
} catch {}
|
||||
this._playing = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async getPosition(): Promise<number> {
|
||||
return this._position;
|
||||
}
|
||||
|
||||
async getDuration(): Promise<number> {
|
||||
return this._duration;
|
||||
}
|
||||
|
||||
isPlaying(): boolean {
|
||||
return this._playing;
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.stop();
|
||||
}
|
||||
}
|
||||
|
||||
// ── afplay Backend (macOS) ───────────────────────────────────────────
|
||||
// Built-in on macOS. Supports volume and rate but no seek or position.
|
||||
|
||||
class AfplayBackend implements AudioBackend {
|
||||
readonly name: BackendName = "afplay";
|
||||
private proc: ReturnType<typeof Bun.spawn> | null = null;
|
||||
private _playing = false;
|
||||
private _paused = false;
|
||||
private _position = 0;
|
||||
private _duration = 0;
|
||||
private _volume = 1;
|
||||
private _speed = 1;
|
||||
private _url = "";
|
||||
private startTime = 0;
|
||||
private pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
async play(url: string, opts?: PlayOptions): Promise<void> {
|
||||
await this.stop();
|
||||
|
||||
this._url = url;
|
||||
this._volume = opts?.volume ?? 1;
|
||||
this._speed = opts?.speed ?? 1;
|
||||
this._position = opts?.startPosition ?? 0;
|
||||
|
||||
this.spawnProcess();
|
||||
}
|
||||
|
||||
private spawnProcess(): void {
|
||||
// afplay supports --volume (0-1) and --rate
|
||||
const args = [
|
||||
"afplay",
|
||||
"--volume",
|
||||
String(this._volume),
|
||||
"--rate",
|
||||
String(this._speed),
|
||||
];
|
||||
|
||||
if (this._position > 0) {
|
||||
args.push(
|
||||
"--time",
|
||||
String(this._duration > 0 ? this._duration - this._position : 0),
|
||||
);
|
||||
}
|
||||
|
||||
args.push(this._url);
|
||||
|
||||
this.proc = Bun.spawn(args, {
|
||||
stdout: "ignore",
|
||||
stderr: "ignore",
|
||||
stdin: "ignore",
|
||||
});
|
||||
|
||||
this._playing = true;
|
||||
this._paused = false;
|
||||
this.startTime = Date.now();
|
||||
this.startPolling();
|
||||
|
||||
this.proc.exited
|
||||
.then(() => {
|
||||
this._playing = false;
|
||||
this.stopPolling();
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
private startPolling(): void {
|
||||
this.stopPolling();
|
||||
this.pollTimer = setInterval(() => {
|
||||
if (!this._playing) return;
|
||||
const elapsed = ((Date.now() - this.startTime) / 1000) * this._speed;
|
||||
this._position = this._position + elapsed;
|
||||
this.startTime = Date.now();
|
||||
}, 500);
|
||||
}
|
||||
|
||||
private stopPolling(): void {
|
||||
if (this.pollTimer) {
|
||||
clearInterval(this.pollTimer);
|
||||
this.pollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
async pause(): Promise<void> {
|
||||
if (this.proc) {
|
||||
const pid = (this.proc as unknown as { pid?: number } | null)?.pid;
|
||||
try {
|
||||
if (pid) process.kill(pid, "SIGSTOP");
|
||||
} catch {}
|
||||
this._paused = true;
|
||||
}
|
||||
this._playing = false;
|
||||
this.stopPolling();
|
||||
}
|
||||
|
||||
async resume(): Promise<void> {
|
||||
if (!this._url) return;
|
||||
if (this.proc && this._paused) {
|
||||
const pid = (this.proc as unknown as { pid?: number } | null)?.pid;
|
||||
try {
|
||||
if (pid) process.kill(pid, "SIGCONT");
|
||||
} catch {}
|
||||
this._paused = false;
|
||||
this._playing = true;
|
||||
this.startTime = Date.now();
|
||||
this.startPolling();
|
||||
return;
|
||||
}
|
||||
this.spawnProcess();
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
this.stopPolling();
|
||||
if (this.proc) {
|
||||
try {
|
||||
this.proc.kill();
|
||||
} catch {}
|
||||
this.proc = null;
|
||||
}
|
||||
this._playing = false;
|
||||
this._paused = false;
|
||||
this._position = 0;
|
||||
this._url = "";
|
||||
}
|
||||
|
||||
async seek(seconds: number): Promise<void> {
|
||||
this._position = seconds;
|
||||
if (this._playing && this._url) {
|
||||
if (this.proc) {
|
||||
try {
|
||||
this.proc.kill();
|
||||
} catch {}
|
||||
this.proc = null;
|
||||
}
|
||||
this.spawnProcess();
|
||||
const pid = (this.proc as unknown as { pid?: number } | null)?.pid;
|
||||
if (this._paused && pid) {
|
||||
try {
|
||||
process.kill(pid, "SIGSTOP");
|
||||
} catch {}
|
||||
this._playing = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async setVolume(volume: number): Promise<void> {
|
||||
this._volume = volume;
|
||||
// Restart the process with new volume to apply immediately
|
||||
if (this._url && (this._playing || this._paused)) {
|
||||
this.stopPolling();
|
||||
if (this.proc) {
|
||||
try {
|
||||
this.proc.kill();
|
||||
} catch {}
|
||||
this.proc = null;
|
||||
}
|
||||
this.spawnProcess();
|
||||
const pid = (this.proc as unknown as { pid?: number } | null)?.pid;
|
||||
if (this._paused && pid) {
|
||||
try {
|
||||
process.kill(pid, "SIGSTOP");
|
||||
} catch {}
|
||||
this._playing = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async setSpeed(speed: number): Promise<void> {
|
||||
this._speed = speed;
|
||||
// Restart the process with new rate to apply immediately
|
||||
if (this._url && (this._playing || this._paused)) {
|
||||
this.stopPolling();
|
||||
if (this.proc) {
|
||||
try {
|
||||
this.proc.kill();
|
||||
} catch {}
|
||||
this.proc = null;
|
||||
}
|
||||
this.spawnProcess();
|
||||
const pid = (this.proc as unknown as { pid?: number } | null)?.pid;
|
||||
if (this._paused && pid) {
|
||||
try {
|
||||
process.kill(pid, "SIGSTOP");
|
||||
} catch {}
|
||||
this._playing = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async getPosition(): Promise<number> {
|
||||
return this._position;
|
||||
}
|
||||
|
||||
async getDuration(): Promise<number> {
|
||||
return this._duration;
|
||||
}
|
||||
|
||||
isPlaying(): boolean {
|
||||
return this._playing;
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.stop();
|
||||
}
|
||||
}
|
||||
|
||||
// ── System Backend (open/xdg-open) ───────────────────────────────────
|
||||
// Fire-and-forget. Opens the URL in the default handler. No control.
|
||||
|
||||
class SystemBackend implements AudioBackend {
|
||||
readonly name: BackendName = "system";
|
||||
private _playing = false;
|
||||
|
||||
async play(url: string): Promise<void> {
|
||||
const os = platform();
|
||||
const cmd =
|
||||
os === "darwin" ? "open" : os === "win32" ? "start" : "xdg-open";
|
||||
|
||||
Bun.spawn([cmd, url], {
|
||||
stdout: "ignore",
|
||||
stderr: "ignore",
|
||||
stdin: "ignore",
|
||||
});
|
||||
|
||||
this._playing = true;
|
||||
}
|
||||
|
||||
async pause(): Promise<void> {
|
||||
this._playing = false;
|
||||
}
|
||||
async resume(): Promise<void> {
|
||||
this._playing = true;
|
||||
}
|
||||
async stop(): Promise<void> {
|
||||
this._playing = false;
|
||||
}
|
||||
async seek(): Promise<void> {}
|
||||
async setVolume(): Promise<void> {}
|
||||
async setSpeed(): Promise<void> {}
|
||||
async getPosition(): Promise<number> {
|
||||
return 0;
|
||||
}
|
||||
async getDuration(): Promise<number> {
|
||||
return 0;
|
||||
}
|
||||
isPlaying(): boolean {
|
||||
return this._playing;
|
||||
}
|
||||
dispose(): void {
|
||||
this._playing = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── No-op Backend ────────────────────────────────────────────────────
|
||||
|
||||
class NoopBackend implements AudioBackend {
|
||||
@@ -896,53 +432,6 @@ export function detectPlayers(): DetectedPlayer[] {
|
||||
});
|
||||
}
|
||||
|
||||
const ffplayPath = which("ffplay");
|
||||
if (ffplayPath) {
|
||||
players.push({
|
||||
name: "ffplay",
|
||||
path: ffplayPath,
|
||||
capabilities: {
|
||||
seek: true,
|
||||
volume: true,
|
||||
speed: false,
|
||||
positionTracking: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const os = platform();
|
||||
if (os === "darwin") {
|
||||
const afplayPath = which("afplay");
|
||||
if (afplayPath) {
|
||||
players.push({
|
||||
name: "afplay",
|
||||
path: afplayPath,
|
||||
capabilities: {
|
||||
seek: true,
|
||||
volume: true,
|
||||
speed: true,
|
||||
positionTracking: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// System open is always available as fallback
|
||||
const openCmd =
|
||||
os === "darwin" ? "open" : os === "win32" ? "start" : "xdg-open";
|
||||
if (which(openCmd)) {
|
||||
players.push({
|
||||
name: "system",
|
||||
path: which(openCmd),
|
||||
capabilities: {
|
||||
seek: false,
|
||||
volume: false,
|
||||
speed: false,
|
||||
positionTracking: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return players;
|
||||
}
|
||||
|
||||
@@ -953,14 +442,7 @@ export function createAudioBackend(preferred?: BackendName): AudioBackend {
|
||||
// An explicit `preferred` argument still wins.
|
||||
if (!preferred) {
|
||||
const envPref = process.env.PODTUI_AUDIO_BACKEND as BackendName | undefined;
|
||||
if (
|
||||
envPref &&
|
||||
(envPref === "mpv" ||
|
||||
envPref === "ffplay" ||
|
||||
envPref === "afplay" ||
|
||||
envPref === "system" ||
|
||||
envPref === "none")
|
||||
) {
|
||||
if (envPref && (envPref === "mpv" || envPref === "none")) {
|
||||
preferred = envPref;
|
||||
}
|
||||
}
|
||||
@@ -970,25 +452,13 @@ export function createAudioBackend(preferred?: BackendName): AudioBackend {
|
||||
if (backend) return backend;
|
||||
}
|
||||
|
||||
// Auto-detect in priority order
|
||||
const players = detectPlayers();
|
||||
if (players.length === 0) return new NoopBackend();
|
||||
|
||||
return createBackendByName(players[0].name) ?? new NoopBackend();
|
||||
return which("mpv") ? new MpvBackend() : new NoopBackend();
|
||||
}
|
||||
|
||||
function createBackendByName(name: BackendName): AudioBackend | null {
|
||||
switch (name) {
|
||||
case "mpv":
|
||||
return which("mpv") ? new MpvBackend() : null;
|
||||
case "ffplay":
|
||||
return which("ffplay") ? new FfplayBackend() : null;
|
||||
case "afplay":
|
||||
return platform() === "darwin" && which("afplay")
|
||||
? new AfplayBackend()
|
||||
: null;
|
||||
case "system":
|
||||
return new SystemBackend();
|
||||
case "none":
|
||||
return new NoopBackend();
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*
|
||||
* The `q` (quit) action routes through `process.exit(0)`, which bypasses
|
||||
* Solid's onCleanup (where useAudio's onCleanup disposes the backend). To
|
||||
* keep spawned players (mpv/ffplay/afplay) from surviving the host, useAudio
|
||||
* keep spawned players (mpv) from surviving the host, useAudio
|
||||
* registers a `process.on("exit")` handler that synchronously disposes the
|
||||
* backend. The exit handler's whole job is "kill the child process", so this
|
||||
* test pins the contract directly: a backend holding a real spawned subprocess
|
||||
@@ -11,7 +11,7 @@
|
||||
*
|
||||
* Uses a real `Bun.spawn(["sleep", "60"])` subprocess as a stand-in for the
|
||||
* player process, injected into the (private) `proc` slot of an MpvBackend —
|
||||
* mpv/ffplay/afplay all share the identical kill-on-dispose pattern, so
|
||||
* mpv is the only real backend, and it uses the kill-on-dispose
|
||||
* exercising one is enough to guard the family.
|
||||
*/
|
||||
import { test, expect } from "bun:test";
|
||||
|
||||
Reference in New Issue
Block a user