diff --git a/README.md b/README.md index 0b3e0f0..090d3a5 100644 --- a/README.md +++ b/README.md @@ -2,10 +2,272 @@ [![CI][ci-badge]][ci-url] -Native audio playback plugin for Tauri 2.x apps. +Headless, state-driven audio playback API for Tauri 2.x apps with native +transport control integration. -This plugin provides a cross-platform interface for native audio -playback from Tauri applications. +This plugin provides a cross-platform audio playback interface with transport +controls (play, pause, stop, seek), volume/rate settings, and OS media +integration (lock screen, notification shade, headphone controls). It is +designed to be wrapped by a consuming app's own API layer. -[ci-badge]: https://img.shields.io/github/actions/workflow/status/silvermine/tauri-plugin-audio/ci.yml +[ci-badge]: https://github.com/silvermine/tauri-plugin-audio/actions/workflows/ci.yml/badge.svg [ci-url]: https://github.com/silvermine/tauri-plugin-audio/actions/workflows/ci.yml + +## Features + + * State-machine-driven playback with type-safe action gating + * OS transport control integration via metadata (title, artist, artwork) + * Real-time state change events (status, time, volume, etc.) + * Volume, mute, playback rate, and loop controls + * Cross-platform support (Windows, iOS, Android) + +| Platform | Supported | +| -------- | --------- | +| Windows | Planned | +| macOS | Planned | +| Android | Planned | +| iOS | Planned | + +## Getting Started + +### Installation + +1. Install NPM dependencies: + + ```bash + npm install + ``` + +2. Build the TypeScript bindings: + + ```bash + npm run build + ``` + +3. Build the Rust plugin: + + ```bash + cargo build + ``` + +### Tests + +Run all tests (TypeScript and Rust): + +```bash +npm test +``` + +Run TypeScript tests only: + +```bash +npm run test:ts +``` + +Run Rust tests only: + +```bash +cargo test --workspace --lib +``` + +## Install + +_This plugin requires a Rust version of at least **1.89**_ + +### Rust + +Add the plugin to your `Cargo.toml`: + +`src-tauri/Cargo.toml` + +```toml +[dependencies] +tauri-plugin-audio = { git = "https://github.com/silvermine/tauri-plugin-audio" } +``` + +### JavaScript/TypeScript + +Install the JavaScript bindings: + +```sh +npm install @silvermine/tauri-plugin-audio +``` + +## Usage + +### Prerequisites + +Initialize the plugin in your `tauri::Builder`: + +```rust +fn main() { + tauri::Builder::default() + .plugin(tauri_plugin_audio::init()) + .run(tauri::generate_context!()) + .expect("error while running tauri application"); +} +``` + +### API + +#### Get the player + +```ts +import { getPlayer, PlaybackStatus } from '@silvermine/tauri-plugin-audio'; + +async function checkPlayer() { + const player = await getPlayer(); + + console.debug(`Status: ${player.status}, Time: ${player.currentTime}`); +} +``` + +#### Load, play, pause, stop, or seek + +The API uses discriminated unions with type guards for compile-time safety. +Only valid transport actions are available based on the player's status. + +```ts +import { + getPlayer, PlaybackStatus, hasAction, AudioAction, +} from '@silvermine/tauri-plugin-audio'; + +async function loadAndPlay() { + const player = await getPlayer(); + + if (player.status === PlaybackStatus.Idle) { + const { player: ready } = await player.load( + 'https://example.com/song.mp3', + { + title: 'My Song', + artist: 'Artist Name', + artwork: 'https://example.com/cover.jpg', + }, + ); + + await ready.play(); + } +} + +async function managePlayback() { + const player = await getPlayer(); + + if (hasAction(player, AudioAction.Pause)) { + await player.pause(); + } else if (hasAction(player, AudioAction.Play)) { + await player.play(); + } +} +``` + +#### Adjust settings + +Volume, mute, playback rate, and loop controls are always available +regardless of playback status. + +```ts +import { getPlayer } from '@silvermine/tauri-plugin-audio'; + +async function adjustSettings() { + const player = await getPlayer(); + + await player.setVolume(0.5); + await player.setMuted(false); + await player.setPlaybackRate(1.5); + await player.setLoop(true); +} +``` + +#### Listen for state changes + +`listen` receives updates for state transitions (status changes, +volume, settings, errors). + +```ts +import { getPlayer, PlaybackStatus } from '@silvermine/tauri-plugin-audio'; + +async function watchPlayback() { + const player = await getPlayer(); + + const unlisten = await player.listen((updated) => { + console.debug(`Status: ${updated.status}`); + + if (updated.status === PlaybackStatus.Ended) { + console.debug('Playback finished'); + } + }); + + // To stop listening: + unlisten(); +} +``` + +#### Listen for time updates + +`onTimeUpdate` receives lightweight, high-frequency updates +(~250ms) carrying only `currentTime` and `duration`, avoiding the +overhead of serializing the full player state on every tick. + +```ts +import { getPlayer } from '@silvermine/tauri-plugin-audio'; + +async function trackProgress() { + const player = await getPlayer(); + + const unlisten = await player.onTimeUpdate((time) => { + const pct = time.duration > 0 + ? (time.currentTime / time.duration) * 100 + : 0; + + console.debug(`${time.currentTime}s / ${time.duration}s (${pct.toFixed(1)}%)`); + }); + + // To stop listening: + unlisten(); +} +``` + +### State Machine + +The player follows a state machine where transport actions are gated by +the current `PlaybackStatus`: + +| Status | Allowed Actions | +| --------- | ---------------------------- | +| Idle | load | +| Loading | stop | +| Ready | play, seek, stop | +| Playing | pause, seek, stop | +| Paused | play, seek, stop | +| Ended | play, seek, load, stop | +| Error | load | + +Settings (`setVolume`, `setMuted`, `setPlaybackRate`, `setLoop`), +`listen`, and `onTimeUpdate` are always available regardless of +status. + +## Development Standards + +This project follows the +[Silvermine standardization](https://github.com/silvermine/standardization) +guidelines. Key standards include: + + * **EditorConfig**: Consistent editor settings across the team + * **Markdownlint**: Markdown linting for documentation + * **Commitlint**: Conventional commit message format + * **Code Style**: 3-space indentation, LF line endings + +### Running Standards Checks + +```bash +npm run standards +``` + +## License + +MIT + +## Contributing + +Contributions are welcome! Please follow the established coding standards +and commit message conventions. diff --git a/build.rs b/build.rs index d10fb02..33ab4f7 100644 --- a/build.rs +++ b/build.rs @@ -1,4 +1,16 @@ -const COMMANDS: &[&str] = &[]; +const COMMANDS: &[&str] = &[ + "load", + "play", + "pause", + "stop", + "seek", + "set_volume", + "set_muted", + "set_playback_rate", + "set_loop", + "get_state", + "is_native", +]; fn main() { tauri_plugin::Builder::new(COMMANDS).build(); diff --git a/guest-js/actions.ts b/guest-js/actions.ts new file mode 100644 index 0000000..4242706 --- /dev/null +++ b/guest-js/actions.ts @@ -0,0 +1,214 @@ +import { listen, type UnlistenFn } from '@tauri-apps/api/event'; +import { addPluginListener, invoke } from '@tauri-apps/api/core'; +import { + AllAudioActions, AudioAction, AudioActionResponse, AudioMetadata, + Player, PlayerControls, PlayerState, PlaybackStatus, + PlayerWithAnyStatus, TimeUpdate, allowedActions, +} from './types'; + +/** + * Generic manager for plugin event subscriptions. Handles lazy setup/teardown + * of the global listener (native plugin or Tauri event) and dispatches + * transformed payloads to registered listeners. + * + * @typeParam TRaw - The raw event payload type from the plugin. + * @typeParam TOut - The transformed type dispatched to listeners. + */ +class PluginEventManager { + private _listeners: Set<(value: TOut) => void> = new Set(); + private _eventUnlistenFn: UnlistenFn | null = null; + private _pluginListener: { unregister: () => void } | null = null; + private _pendingSetup: Promise | null = null; + private readonly _pluginEvent: string; + private readonly _tauriEvent: string; + private readonly _transform: (raw: TRaw) => TOut; + + public constructor( + pluginEvent: string, + tauriEvent: string, + transform: (raw: TRaw) => TOut + ) { + this._pluginEvent = pluginEvent; + this._tauriEvent = tauriEvent; + this._transform = transform; + } + + public async addListener(listener: (value: TOut) => void): Promise<() => void> { + await this._ensureGlobalListener(); + this._listeners.add(listener); + + return () => { + this._listeners.delete(listener); + this._cleanupGlobalListener(); + }; + } + + private _ensureGlobalListener(): Promise { + if (this._eventUnlistenFn || this._pluginListener) { + return Promise.resolve(); + } + + if (!this._pendingSetup) { + this._pendingSetup = this._setupGlobalListener().finally(() => { + this._pendingSetup = null; + }); + } + + return this._pendingSetup; + } + + private async _setupGlobalListener(): Promise { + const isNative = await invoke('plugin:audio|is_native'); + + if (isNative) { + this._pluginListener = await addPluginListener( + 'audio', + this._pluginEvent, + (event: TRaw) => { + this._notifyListeners(event); + } + ); + } else { + this._eventUnlistenFn = await listen( + this._tauriEvent, + (event) => { + this._notifyListeners(event.payload); + } + ); + } + } + + private _notifyListeners(raw: TRaw): void { + const value = this._transform(raw); + + this._listeners.forEach((listener) => { listener(value); }); + } + + private _cleanupGlobalListener(): void { + if (this._listeners.size > 0) { + return; + } + + if (this._eventUnlistenFn) { + this._eventUnlistenFn(); + this._eventUnlistenFn = null; + } + + if (this._pluginListener) { + this._pluginListener.unregister(); + this._pluginListener = null; + } + } +} + +/** State-change events: status transitions, settings, errors. */ +const audioEventManager = new PluginEventManager, PlayerWithAnyStatus>( + 'state-changed', + 'tauri-plugin-audio:state-changed', + (event) => { return attachPlayer(event); } +); + +/** High-frequency time-update events (~250ms during playback). */ +const timeUpdateEventManager = new PluginEventManager( + 'time-update', + 'tauri-plugin-audio:time-update', + (event) => { return event; } +); + +async function sendAction( + action: A, + args: Record +): Promise> { + const response = await invoke>(`plugin:audio|${action}`, args); + + response.player = attachPlayer(response.player); + return response; +} + +async function sendSetting( + command: string, + args: Record +): Promise { + const state = await invoke>(`plugin:audio|${command}`, args); + + return attachPlayer(state); +} + +const transportActions = { + async load(src: string, metadata?: AudioMetadata) { + return sendAction(AudioAction.Load, { src, metadata }); + }, + + async play() { + return sendAction(AudioAction.Play, {}); + }, + + async pause() { + return sendAction(AudioAction.Pause, {}); + }, + + async stop() { + return sendAction(AudioAction.Stop, {}); + }, + + async seek(position: number) { + return sendAction(AudioAction.Seek, { position }); + }, +} satisfies AllAudioActions; + +const playerControls = { + listen(listener: (player: PlayerWithAnyStatus) => void): Promise { + return audioEventManager.addListener(listener); + }, + + onTimeUpdate(listener: (time: TimeUpdate) => void): Promise { + return timeUpdateEventManager.addListener(listener); + }, + + async setVolume(level: number) { + return sendSetting('set_volume', { level }); + }, + + async setMuted(muted: boolean) { + return sendSetting('set_muted', { muted }); + }, + + async setPlaybackRate(rate: number) { + return sendSetting('set_playback_rate', { rate }); + }, + + async setLoop(loop: boolean) { + return sendSetting('set_loop', { looping: loop }); + }, +} satisfies PlayerControls; + +/** + * Attaches transport actions (gated by status) and player controls (always available) + * to a raw {@link PlayerState}, producing a {@link Player} object. + * + * @param state - The deserialized player state from the plugin. + */ +export function attachPlayer(state: PlayerState): Player { + const player = { ...state } satisfies PlayerState; + + // Attach state-gated transport actions. + const actionsForStatus = allowedActions[state.status]; + + for (const actionName of actionsForStatus) { + Object.defineProperty(player, actionName, { + value: transportActions[actionName], + }); + } + + // Attach always-available controls. + for (const [ name, fn ] of Object.entries(playerControls)) { + Object.defineProperty(player, name, { + value: fn, + }); + } + + // SAFETY: Transport actions and controls were attached above via + // Object.defineProperty, matching the shape of Player. TypeScript + // cannot verify dynamically-added properties. + return player as unknown as Player; +} diff --git a/guest-js/index.test.ts b/guest-js/index.test.ts new file mode 100644 index 0000000..0529c20 --- /dev/null +++ b/guest-js/index.test.ts @@ -0,0 +1,390 @@ +/** + * Sanity checks to test the bridge between TypeScript and the Tauri commands. + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mockIPC, clearMocks } from '@tauri-apps/api/mocks'; +import { getPlayer } from './index'; +import { + PlaybackStatus, + AudioAction, + hasAction, + hasAnyAction, +} from './types'; +import { attachPlayer } from './actions'; + +let lastCmd = '', + lastArgs: Record = {}; + +const IDLE_STATE = { + status: PlaybackStatus.Idle, + src: null, + title: null, + artist: null, + artwork: null, + currentTime: 0, + duration: 0, + volume: 1, + muted: false, + playbackRate: 1, + loop: false, + error: null, +}; + +const READY_STATE = { + ...IDLE_STATE, + status: PlaybackStatus.Ready, + src: 'https://example.com/song.mp3', + title: 'Test Song', + artist: 'Test Artist', +}; + +const PLAYING_STATE = { + ...READY_STATE, + status: PlaybackStatus.Playing, + duration: 180, + currentTime: 42, +}; + +const PAUSED_STATE = { + ...PLAYING_STATE, + status: PlaybackStatus.Paused, +}; + +const ENDED_STATE = { + ...PLAYING_STATE, + status: PlaybackStatus.Ended, + currentTime: 180, +}; + +const ACTION_RESPONSE_BASE = { + isExpectedStatus: true, +}; + +beforeEach(() => { + mockIPC((cmd, args) => { + lastCmd = cmd; + lastArgs = args as Record; + + if (cmd === 'plugin:audio|get_state') { + return IDLE_STATE; + } + if (cmd === 'plugin:audio|load') { + return { + ...ACTION_RESPONSE_BASE, + expectedStatus: PlaybackStatus.Ready, + player: READY_STATE, + }; + } + if (cmd === 'plugin:audio|play') { + return { + ...ACTION_RESPONSE_BASE, + expectedStatus: PlaybackStatus.Playing, + player: PLAYING_STATE, + }; + } + if (cmd === 'plugin:audio|pause') { + return { + ...ACTION_RESPONSE_BASE, + expectedStatus: PlaybackStatus.Paused, + player: PAUSED_STATE, + }; + } + if (cmd === 'plugin:audio|stop') { + return { + ...ACTION_RESPONSE_BASE, + expectedStatus: PlaybackStatus.Idle, + player: IDLE_STATE, + }; + } + if (cmd === 'plugin:audio|seek') { + return { + ...ACTION_RESPONSE_BASE, + expectedStatus: PlaybackStatus.Playing, + player: { ...PLAYING_STATE, currentTime: (args as { position: number }).position }, + }; + } + if (cmd === 'plugin:audio|set_volume') { + return { ...PLAYING_STATE, volume: (args as { level: number }).level }; + } + if (cmd === 'plugin:audio|set_muted') { + return { ...PLAYING_STATE, muted: (args as { muted: boolean }).muted }; + } + if (cmd === 'plugin:audio|set_playback_rate') { + return { ...PLAYING_STATE, playbackRate: (args as { rate: number }).rate }; + } + if (cmd === 'plugin:audio|set_loop') { + return { ...PLAYING_STATE, loop: (args as { looping: boolean }).looping }; + } + if (cmd === 'plugin:audio|is_native') { + return false; + } + return undefined; + }); +}); + +afterEach(() => { return clearMocks(); }); + +describe('getPlayer', () => { + it('invokes get_state and returns a player with actions attached', async () => { + const player = await getPlayer(); + + expect(lastCmd).toBe('plugin:audio|get_state'); + expect(player.status).toBe(PlaybackStatus.Idle); + expect(hasAction(player, AudioAction.Load)).toBe(true); + }); +}); + +describe('transport actions', () => { + it('load — sends src and metadata, returns Ready player', async () => { + const player = await getPlayer(); + + if (!hasAction(player, AudioAction.Load)) { + throw new Error('expected load action'); + } + const response = await player.load('https://example.com/song.mp3', { + title: 'Test Song', + artist: 'Test Artist', + }); + + expect(lastCmd).toBe('plugin:audio|load'); + expect(lastArgs.src).toBe('https://example.com/song.mp3'); + expect((lastArgs.metadata as Record).title).toBe('Test Song'); + expect(response.isExpectedStatus).toBe(true); + expect(response.player.status).toBe(PlaybackStatus.Ready); + }); + + it('play — returns Playing player', async () => { + const ready = attachPlayer(READY_STATE); + + if (!hasAction(ready, AudioAction.Play)) { + throw new Error('expected play action'); + } + const response = await ready.play(); + + expect(lastCmd).toBe('plugin:audio|play'); + expect(response.isExpectedStatus).toBe(true); + expect(response.player.status).toBe(PlaybackStatus.Playing); + }); + + it('pause — returns Paused player', async () => { + const playing = attachPlayer(PLAYING_STATE); + + if (!hasAction(playing, AudioAction.Pause)) { + throw new Error('expected pause action'); + } + const response = await playing.pause(); + + expect(lastCmd).toBe('plugin:audio|pause'); + expect(response.isExpectedStatus).toBe(true); + expect(response.player.status).toBe(PlaybackStatus.Paused); + }); + + it('stop — returns Idle player', async () => { + const playing = attachPlayer(PLAYING_STATE); + + if (!hasAction(playing, AudioAction.Stop)) { + throw new Error('expected stop action'); + } + const response = await playing.stop(); + + expect(lastCmd).toBe('plugin:audio|stop'); + expect(response.isExpectedStatus).toBe(true); + expect(response.player.status).toBe(PlaybackStatus.Idle); + }); + + it('seek — sends position, returns player at new time', async () => { + const playing = attachPlayer(PLAYING_STATE); + + if (!hasAction(playing, AudioAction.Seek)) { + throw new Error('expected seek action'); + } + const response = await playing.seek(90); + + expect(lastCmd).toBe('plugin:audio|seek'); + expect(lastArgs.position).toBe(90); + expect(response.isExpectedStatus).toBe(true); + expect(response.player.currentTime).toBe(90); + }); + + it('handles errors thrown by the backend', async () => { + mockIPC(() => { throw new Error('audio error'); }); + + const player = await getPlayer().catch(() => { + return attachPlayer(IDLE_STATE); + }); + + if (!hasAction(player, AudioAction.Load)) { + throw new Error('expected load action'); + } + await expect(player.load('test.mp3')).rejects.toThrow('audio error'); + }); +}); + +describe('player controls (always available)', () => { + it('setVolume — sends level, returns updated player', async () => { + const player = attachPlayer(PLAYING_STATE); + + const updated = await player.setVolume(0.5); + + expect(lastCmd).toBe('plugin:audio|set_volume'); + expect(lastArgs.level).toBe(0.5); + expect(updated.volume).toBe(0.5); + }); + + it('setMuted — sends muted flag, returns updated player', async () => { + const player = attachPlayer(PLAYING_STATE); + + const updated = await player.setMuted(true); + + expect(lastCmd).toBe('plugin:audio|set_muted'); + expect(lastArgs.muted).toBe(true); + expect(updated.muted).toBe(true); + }); + + it('setPlaybackRate — sends rate, returns updated player', async () => { + const player = attachPlayer(PLAYING_STATE); + + const updated = await player.setPlaybackRate(2.0); + + expect(lastCmd).toBe('plugin:audio|set_playback_rate'); + expect(lastArgs.rate).toBe(2.0); + expect(updated.playbackRate).toBe(2.0); + }); + + it('setLoop — sends looping flag, returns updated player', async () => { + const player = attachPlayer(PLAYING_STATE); + + const updated = await player.setLoop(true); + + expect(lastCmd).toBe('plugin:audio|set_loop'); + expect(lastArgs.looping).toBe(true); + expect(updated.loop).toBe(true); + }); + + it('controls are available in Idle state', () => { + const player = attachPlayer(IDLE_STATE); + + expect(typeof player.setVolume).toBe('function'); + expect(typeof player.setMuted).toBe('function'); + expect(typeof player.setPlaybackRate).toBe('function'); + expect(typeof player.setLoop).toBe('function'); + expect(typeof player.listen).toBe('function'); + expect(typeof player.onTimeUpdate).toBe('function'); + }); + + it('controls are available in Error state', () => { + const player = attachPlayer({ ...IDLE_STATE, status: PlaybackStatus.Error, error: 'fail' }); + + expect(typeof player.setVolume).toBe('function'); + expect(typeof player.setMuted).toBe('function'); + expect(typeof player.setPlaybackRate).toBe('function'); + expect(typeof player.setLoop).toBe('function'); + expect(typeof player.listen).toBe('function'); + expect(typeof player.onTimeUpdate).toBe('function'); + }); +}); + +describe('state machine — action availability', () => { + it('Idle: only load is available', () => { + const player = attachPlayer(IDLE_STATE); + + expect(hasAction(player, AudioAction.Load)).toBe(true); + expect(hasAction(player, AudioAction.Play)).toBe(false); + expect(hasAction(player, AudioAction.Pause)).toBe(false); + expect(hasAction(player, AudioAction.Stop)).toBe(false); + expect(hasAction(player, AudioAction.Seek)).toBe(false); + }); + + it('Loading: only stop is available', () => { + const player = attachPlayer({ ...IDLE_STATE, status: PlaybackStatus.Loading }); + + expect(hasAction(player, AudioAction.Stop)).toBe(true); + expect(hasAction(player, AudioAction.Load)).toBe(false); + expect(hasAction(player, AudioAction.Play)).toBe(false); + expect(hasAction(player, AudioAction.Pause)).toBe(false); + }); + + it('Ready: play, seek, and stop are available', () => { + const player = attachPlayer(READY_STATE); + + expect(hasAction(player, AudioAction.Play)).toBe(true); + expect(hasAction(player, AudioAction.Seek)).toBe(true); + expect(hasAction(player, AudioAction.Stop)).toBe(true); + expect(hasAction(player, AudioAction.Pause)).toBe(false); + expect(hasAction(player, AudioAction.Load)).toBe(false); + }); + + it('Playing: pause, seek, and stop are available', () => { + const player = attachPlayer(PLAYING_STATE); + + expect(hasAction(player, AudioAction.Pause)).toBe(true); + expect(hasAction(player, AudioAction.Seek)).toBe(true); + expect(hasAction(player, AudioAction.Stop)).toBe(true); + expect(hasAction(player, AudioAction.Play)).toBe(false); + expect(hasAction(player, AudioAction.Load)).toBe(false); + }); + + it('Paused: play, seek, and stop are available', () => { + const player = attachPlayer(PAUSED_STATE); + + expect(hasAction(player, AudioAction.Play)).toBe(true); + expect(hasAction(player, AudioAction.Seek)).toBe(true); + expect(hasAction(player, AudioAction.Stop)).toBe(true); + expect(hasAction(player, AudioAction.Pause)).toBe(false); + expect(hasAction(player, AudioAction.Load)).toBe(false); + }); + + it('Ended: play, seek, load, and stop are available', () => { + const player = attachPlayer(ENDED_STATE); + + expect(hasAction(player, AudioAction.Play)).toBe(true); + expect(hasAction(player, AudioAction.Seek)).toBe(true); + expect(hasAction(player, AudioAction.Load)).toBe(true); + expect(hasAction(player, AudioAction.Stop)).toBe(true); + expect(hasAction(player, AudioAction.Pause)).toBe(false); + }); + + it('Error: only load is available', () => { + const player = attachPlayer({ ...IDLE_STATE, status: PlaybackStatus.Error, error: 'fail' }); + + expect(hasAction(player, AudioAction.Load)).toBe(true); + expect(hasAction(player, AudioAction.Play)).toBe(false); + expect(hasAction(player, AudioAction.Pause)).toBe(false); + expect(hasAction(player, AudioAction.Stop)).toBe(false); + expect(hasAction(player, AudioAction.Seek)).toBe(false); + }); + + it('hasAnyAction returns true for states with transport actions', () => { + expect(hasAnyAction(attachPlayer(IDLE_STATE))).toBe(true); + expect(hasAnyAction(attachPlayer(READY_STATE))).toBe(true); + expect(hasAnyAction(attachPlayer(PLAYING_STATE))).toBe(true); + expect(hasAnyAction(attachPlayer(PAUSED_STATE))).toBe(true); + expect(hasAnyAction(attachPlayer(ENDED_STATE))).toBe(true); + expect(hasAnyAction(attachPlayer({ ...IDLE_STATE, status: PlaybackStatus.Error }))).toBe(true); + expect(hasAnyAction(attachPlayer({ ...IDLE_STATE, status: PlaybackStatus.Loading }))).toBe(true); + }); + + it('attaches only the allowed transport methods as callable functions', () => { + const idle = attachPlayer(IDLE_STATE); + + expect(typeof (idle as unknown as Record).load).toBe('function'); + expect(typeof (idle as unknown as Record).play).toBe('undefined'); + expect(typeof (idle as unknown as Record).pause).toBe('undefined'); + }); + + it('preserves all state fields on the returned object', () => { + const player = attachPlayer(PLAYING_STATE); + + expect(player.status).toBe(PlaybackStatus.Playing); + expect(player.src).toBe('https://example.com/song.mp3'); + expect(player.title).toBe('Test Song'); + expect(player.artist).toBe('Test Artist'); + expect(player.currentTime).toBe(42); + expect(player.duration).toBe(180); + expect(player.volume).toBe(1); + expect(player.muted).toBe(false); + expect(player.playbackRate).toBe(1); + expect(player.loop).toBe(false); + expect(player.error).toBeNull(); + }); +}); diff --git a/guest-js/index.ts b/guest-js/index.ts index cb0ff5c..7383438 100644 --- a/guest-js/index.ts +++ b/guest-js/index.ts @@ -1 +1,43 @@ -export {}; +import { invoke } from '@tauri-apps/api/core'; +import { PlayerState, PlaybackStatus, PlayerWithAnyStatus } from './types'; +import { attachPlayer } from './actions'; +export { attachPlayer }; + +/** + * Gets the current audio player state with transport actions and controls attached. + * + * This is the primary entry point for interacting with the audio plugin. The returned + * {@link Player} object has transport actions (play, pause, etc.) gated by the current + * {@link PlaybackStatus}, plus always-available controls (setVolume, listen, etc.). + * + * @returns The current player state with actions attached. + * + * @example + * ```ts + * import { getPlayer, PlaybackStatus, hasAction, AudioAction } + * from '@silvermine/tauri-plugin-audio'; + * + * const player = await getPlayer(); + * + * if (player.status === PlaybackStatus.Idle) { + * const { player: ready } = await player.load('https://example.com/song.mp3', { + * title: 'My Song', + * artist: 'Artist Name', + * }); + * const { player: playing } = await ready.play(); + * console.log('Now playing:', playing.title); + * } + * + * // Listen for state changes (e.g. time progression): + * const unlisten = await player.listen((updated) => { + * console.log('Time:', updated.currentTime, '/', updated.duration); + * }); + * ``` + */ +export async function getPlayer(): Promise { + const state = await invoke>('plugin:audio|get_state'); + + return attachPlayer(state); +} + +export * from './types'; diff --git a/guest-js/types.ts b/guest-js/types.ts new file mode 100644 index 0000000..5646f13 --- /dev/null +++ b/guest-js/types.ts @@ -0,0 +1,341 @@ +import type { UnlistenFn } from '@tauri-apps/api/event'; + +/** + * Represents the current playback status of the audio player. + * + * Modeled after common media player states (inspired by Vidstack's player state model), + * adapted for headless native audio playback with transport control integration. + * + * Use the `status` field on a {@link Player} object to determine which transport + * actions are available. TypeScript will automatically narrow the available methods + * based on the status. + * + * @example + * ```ts + * if (player.status === PlaybackStatus.Ready) { + * await player.play(); // TypeScript knows play() is available + * } + * ``` + */ +export enum PlaybackStatus { + + /** No audio source is loaded. */ + Idle = 'idle', + + /** + * An audio source is being loaded. Reserved for the real implementation + * where loading is asynchronous. The mock transitions directly from + * Idle to Ready. + */ + Loading = 'loading', + + /** Audio source is loaded and ready to play. */ + Ready = 'ready', + + /** Audio is currently playing. */ + Playing = 'playing', + + /** Audio playback is paused. */ + Paused = 'paused', + + /** Audio playback has reached the end. */ + Ended = 'ended', + + /** An error occurred during loading or playback. */ + Error = 'error', +} + +/** + * Transport actions that are gated by the current {@link PlaybackStatus}. + * + * Only the actions listed in {@link allowedActions} for a given status will be + * attached to the {@link Player} object. + */ +export enum AudioAction { + Load = 'load', + Play = 'play', + Pause = 'pause', + Stop = 'stop', + Seek = 'seek', +} + +/** + * Metadata for the audio source, used for OS transport control integration + * (lock screen, notification shade, headphone controls, etc.). + */ +export interface AudioMetadata { + title?: string; + artist?: string; + artwork?: string; +} + +/** + * The complete state of the audio player at a point in time. + * + * Inspired by Vidstack's player state model, this captures all relevant playback + * properties: source info, timing, volume, and error state. + */ +export interface PlayerState { + status: S; + src: string | null; + title: string | null; + artist: string | null; + artwork: string | null; + currentTime: number; + duration: number; + volume: number; + muted: boolean; + playbackRate: number; + loop: boolean; + error: string | null; +} + +/** + * Response from a transport action (load, play, pause, stop, seek). + * + * Wraps the resulting player state with status-expectation metadata so callers + * can detect unexpected state transitions. + */ +export interface AudioActionResponse { + player: PlayerWithAnyStatus; + expectedStatus: ExpectedStatusesForAction; + isExpectedStatus: boolean; +} + +/** + * Signatures for all transport actions. Only the subset allowed for a given + * {@link PlaybackStatus} will be attached to the {@link Player} object. + */ +export interface AllAudioActions { + + /** + * Load an audio source. + * + * @param src - URL or file path of the audio source. + * @param metadata - Optional metadata for OS transport + * controls (title, artist, artwork). + * @returns The action response with the updated player state. + */ + [AudioAction.Load]: (src: string, metadata?: AudioMetadata) => Promise>; + + /** Start or resume playback. */ + [AudioAction.Play]: () => Promise>; + + /** Pause playback. */ + [AudioAction.Pause]: () => Promise>; + + /** Stop playback and unload the audio source, resetting to Idle. */ + [AudioAction.Stop]: () => Promise>; + + /** + * Seek to a position in the audio. + * + * @param position - The time in seconds to seek to. + * @returns The action response with the updated player state. + */ + [AudioAction.Seek]: (position: number) => Promise>; +} + +/** + * Lightweight time update payload emitted at high frequency during + * playback (typically every 250ms). Separated from full state changes + * to minimize serialization overhead. + */ +export interface TimeUpdate { + currentTime: number; + duration: number; +} + +/** + * Settings and subscriptions that are always available regardless of playback status. + * These do not participate in the state machine gating. + */ +export interface PlayerControls { + + /** + * Listen for changes to the player state. To avoid memory leaks, + * call the `unlisten` function returned by the promise when no + * longer needed. + * + * Receives updates for state transitions (status changes, volume, + * settings, errors). For high-frequency time progression, use + * {@link onTimeUpdate} instead. + * + * @param listener - Callback invoked when the player state + * changes. + * @returns A promise with a function to remove the listener. + * + * @example + * ```ts + * const unlisten = await player.listen((updated) => { + * console.log('Status:', updated.status); + * }); + * + * // To stop listening: + * unlisten(); + * ``` + */ + listen: (listener: (player: PlayerWithAnyStatus) => void) => Promise; + + /** + * Listen for high-frequency time progression updates during + * playback (typically every 250ms). + * + * This is a lightweight event carrying only `currentTime` and + * `duration`, avoiding the overhead of serializing the full + * player state on every tick. + * + * @param listener - Callback invoked on each time update. + * @returns A promise with a function to remove the listener. + * + * @example + * ```ts + * const unlisten = await player.onTimeUpdate((time) => { + * progressBar.value = time.currentTime / time.duration; + * }); + * ``` + */ + onTimeUpdate: (listener: (time: TimeUpdate) => void) => Promise; + + /** + * Set the volume level. + * + * @param level - Volume level between 0.0 (silent) and 1.0 (maximum). + * @returns The updated player state with actions attached. + */ + setVolume: (level: number) => Promise; + + /** + * Mute or unmute the audio. + * + * @param muted - `true` to mute, `false` to unmute. + * @returns The updated player state with actions attached. + */ + setMuted: (muted: boolean) => Promise; + + /** + * Set the playback speed. + * + * @param rate - Playback rate where 1.0 is normal speed. + * @returns The updated player state with actions attached. + */ + setPlaybackRate: (rate: number) => Promise; + + /** + * Enable or disable looping. + * + * @param loop - `true` to loop, `false` for single playback. + * @returns The updated player state with actions attached. + */ + setLoop: (loop: boolean) => Promise; +} + +// Only these transport actions are allowed for each given PlaybackStatus: +export const allowedActions = { + [PlaybackStatus.Idle]: [ + AudioAction.Load, + ], + [PlaybackStatus.Loading]: [ + AudioAction.Stop, + ], + [PlaybackStatus.Ready]: [ + AudioAction.Play, + AudioAction.Seek, + AudioAction.Stop, + ], + [PlaybackStatus.Playing]: [ + AudioAction.Pause, + AudioAction.Seek, + AudioAction.Stop, + ], + [PlaybackStatus.Paused]: [ + AudioAction.Play, + AudioAction.Seek, + AudioAction.Stop, + ], + [PlaybackStatus.Ended]: [ + AudioAction.Play, + AudioAction.Seek, + AudioAction.Load, + AudioAction.Stop, + ], + [PlaybackStatus.Error]: [ + AudioAction.Load, + ], +} as const satisfies Record; + +export const expectedStatusesForAction = { + [AudioAction.Load]: [ PlaybackStatus.Ready ], + [AudioAction.Play]: [ PlaybackStatus.Playing ], + [AudioAction.Pause]: [ PlaybackStatus.Paused ], + [AudioAction.Stop]: [ PlaybackStatus.Idle ], + [AudioAction.Seek]: [ + PlaybackStatus.Ready, + PlaybackStatus.Playing, + PlaybackStatus.Paused, + PlaybackStatus.Ended, + ], +} as const satisfies Record; + +type ActionsFns = Pick; +type AllowedActionsForStatus = ActionsFns extends never ? object : ActionsFns; + +/** + * A player in a specific status, with only the transport actions valid for that status + * plus always-available {@link PlayerControls} (listen, setVolume, etc.). + */ +export type Player = PlayerState & AllowedActionsForStatus & PlayerControls; + +/** + * Union type representing a player in any status. + * + * To narrow to a specific status, use either {@link hasAction} or the `status` + * field as a discriminator. + * + * @example + * ```ts + * if (hasAction(player, AudioAction.Play)) { + * await player.play(); + * } + * + * // Or: + * if (player.status === PlaybackStatus.Paused) { + * await player.play(); // TypeScript knows play() is available + * } + * ``` + */ +export type PlayerWithAnyStatus = { [T in PlaybackStatus]: Player }[PlaybackStatus]; + +export type ExpectedStatusesForAction = (typeof expectedStatusesForAction)[A][number]; +export type UnexpectedStatusesForAction = Exclude>; + +export type ExpectedStatesForAction = Extract>; +export type UnexpectedStatesForAction = Exclude>; + +/** + * Type guard that checks whether a transport action is available on the given player. + * + * @example + * ```ts + * if (hasAction(player, AudioAction.Pause)) { + * await player.pause(); // TypeScript narrows the type + * } + * ``` + */ +export function hasAction( + player: PlayerWithAnyStatus, + actionName: A +): player is Extract> { + return (allowedActions[player.status] as AudioAction[]).includes(actionName); +} + +/** + * Checks whether the player has any transport actions available. + * + * Currently all statuses have at least one transport action, so this + * always returns `true`. It is provided for forward-compatibility if + * terminal statuses (with no actions) are added in the future. + */ +export function hasAnyAction(player: PlayerWithAnyStatus): boolean { + return allowedActions[player.status].length > 0; +} diff --git a/permissions/autogenerated/commands/get_state.toml b/permissions/autogenerated/commands/get_state.toml new file mode 100644 index 0000000..344da27 --- /dev/null +++ b/permissions/autogenerated/commands/get_state.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-get-state" +description = "Enables the get_state command without any pre-configured scope." +commands.allow = ["get_state"] + +[[permission]] +identifier = "deny-get-state" +description = "Denies the get_state command without any pre-configured scope." +commands.deny = ["get_state"] diff --git a/permissions/autogenerated/commands/is_native.toml b/permissions/autogenerated/commands/is_native.toml new file mode 100644 index 0000000..f382fb6 --- /dev/null +++ b/permissions/autogenerated/commands/is_native.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-is-native" +description = "Enables the is_native command without any pre-configured scope." +commands.allow = ["is_native"] + +[[permission]] +identifier = "deny-is-native" +description = "Denies the is_native command without any pre-configured scope." +commands.deny = ["is_native"] diff --git a/permissions/autogenerated/commands/load.toml b/permissions/autogenerated/commands/load.toml new file mode 100644 index 0000000..f6e47ad --- /dev/null +++ b/permissions/autogenerated/commands/load.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-load" +description = "Enables the load command without any pre-configured scope." +commands.allow = ["load"] + +[[permission]] +identifier = "deny-load" +description = "Denies the load command without any pre-configured scope." +commands.deny = ["load"] diff --git a/permissions/autogenerated/commands/pause.toml b/permissions/autogenerated/commands/pause.toml new file mode 100644 index 0000000..7191547 --- /dev/null +++ b/permissions/autogenerated/commands/pause.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-pause" +description = "Enables the pause command without any pre-configured scope." +commands.allow = ["pause"] + +[[permission]] +identifier = "deny-pause" +description = "Denies the pause command without any pre-configured scope." +commands.deny = ["pause"] diff --git a/permissions/autogenerated/commands/play.toml b/permissions/autogenerated/commands/play.toml new file mode 100644 index 0000000..0231280 --- /dev/null +++ b/permissions/autogenerated/commands/play.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-play" +description = "Enables the play command without any pre-configured scope." +commands.allow = ["play"] + +[[permission]] +identifier = "deny-play" +description = "Denies the play command without any pre-configured scope." +commands.deny = ["play"] diff --git a/permissions/autogenerated/commands/seek.toml b/permissions/autogenerated/commands/seek.toml new file mode 100644 index 0000000..cb21bdc --- /dev/null +++ b/permissions/autogenerated/commands/seek.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-seek" +description = "Enables the seek command without any pre-configured scope." +commands.allow = ["seek"] + +[[permission]] +identifier = "deny-seek" +description = "Denies the seek command without any pre-configured scope." +commands.deny = ["seek"] diff --git a/permissions/autogenerated/commands/set_loop.toml b/permissions/autogenerated/commands/set_loop.toml new file mode 100644 index 0000000..9be7901 --- /dev/null +++ b/permissions/autogenerated/commands/set_loop.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-set-loop" +description = "Enables the set_loop command without any pre-configured scope." +commands.allow = ["set_loop"] + +[[permission]] +identifier = "deny-set-loop" +description = "Denies the set_loop command without any pre-configured scope." +commands.deny = ["set_loop"] diff --git a/permissions/autogenerated/commands/set_muted.toml b/permissions/autogenerated/commands/set_muted.toml new file mode 100644 index 0000000..1bbeae0 --- /dev/null +++ b/permissions/autogenerated/commands/set_muted.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-set-muted" +description = "Enables the set_muted command without any pre-configured scope." +commands.allow = ["set_muted"] + +[[permission]] +identifier = "deny-set-muted" +description = "Denies the set_muted command without any pre-configured scope." +commands.deny = ["set_muted"] diff --git a/permissions/autogenerated/commands/set_playback_rate.toml b/permissions/autogenerated/commands/set_playback_rate.toml new file mode 100644 index 0000000..637d392 --- /dev/null +++ b/permissions/autogenerated/commands/set_playback_rate.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-set-playback-rate" +description = "Enables the set_playback_rate command without any pre-configured scope." +commands.allow = ["set_playback_rate"] + +[[permission]] +identifier = "deny-set-playback-rate" +description = "Denies the set_playback_rate command without any pre-configured scope." +commands.deny = ["set_playback_rate"] diff --git a/permissions/autogenerated/commands/set_volume.toml b/permissions/autogenerated/commands/set_volume.toml new file mode 100644 index 0000000..8c6d2db --- /dev/null +++ b/permissions/autogenerated/commands/set_volume.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-set-volume" +description = "Enables the set_volume command without any pre-configured scope." +commands.allow = ["set_volume"] + +[[permission]] +identifier = "deny-set-volume" +description = "Denies the set_volume command without any pre-configured scope." +commands.deny = ["set_volume"] diff --git a/permissions/autogenerated/commands/stop.toml b/permissions/autogenerated/commands/stop.toml new file mode 100644 index 0000000..6230cb9 --- /dev/null +++ b/permissions/autogenerated/commands/stop.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-stop" +description = "Enables the stop command without any pre-configured scope." +commands.allow = ["stop"] + +[[permission]] +identifier = "deny-stop" +description = "Denies the stop command without any pre-configured scope." +commands.deny = ["stop"] diff --git a/permissions/autogenerated/reference.md b/permissions/autogenerated/reference.md new file mode 100644 index 0000000..0ecc43f --- /dev/null +++ b/permissions/autogenerated/reference.md @@ -0,0 +1,313 @@ +## Default Permission + +Default permissions for the plugin + +#### This default permission set includes the following: + +- `allow-load` +- `allow-play` +- `allow-pause` +- `allow-stop` +- `allow-seek` +- `allow-set-volume` +- `allow-set-muted` +- `allow-set-playback-rate` +- `allow-set-loop` +- `allow-get-state` +- `allow-is-native` + +## Permission Table + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
IdentifierDescription
+ +`audio:allow-get-state` + + + +Enables the get_state command without any pre-configured scope. + +
+ +`audio:deny-get-state` + + + +Denies the get_state command without any pre-configured scope. + +
+ +`audio:allow-is-native` + + + +Enables the is_native command without any pre-configured scope. + +
+ +`audio:deny-is-native` + + + +Denies the is_native command without any pre-configured scope. + +
+ +`audio:allow-load` + + + +Enables the load command without any pre-configured scope. + +
+ +`audio:deny-load` + + + +Denies the load command without any pre-configured scope. + +
+ +`audio:allow-pause` + + + +Enables the pause command without any pre-configured scope. + +
+ +`audio:deny-pause` + + + +Denies the pause command without any pre-configured scope. + +
+ +`audio:allow-play` + + + +Enables the play command without any pre-configured scope. + +
+ +`audio:deny-play` + + + +Denies the play command without any pre-configured scope. + +
+ +`audio:allow-seek` + + + +Enables the seek command without any pre-configured scope. + +
+ +`audio:deny-seek` + + + +Denies the seek command without any pre-configured scope. + +
+ +`audio:allow-set-loop` + + + +Enables the set_loop command without any pre-configured scope. + +
+ +`audio:deny-set-loop` + + + +Denies the set_loop command without any pre-configured scope. + +
+ +`audio:allow-set-muted` + + + +Enables the set_muted command without any pre-configured scope. + +
+ +`audio:deny-set-muted` + + + +Denies the set_muted command without any pre-configured scope. + +
+ +`audio:allow-set-playback-rate` + + + +Enables the set_playback_rate command without any pre-configured scope. + +
+ +`audio:deny-set-playback-rate` + + + +Denies the set_playback_rate command without any pre-configured scope. + +
+ +`audio:allow-set-volume` + + + +Enables the set_volume command without any pre-configured scope. + +
+ +`audio:deny-set-volume` + + + +Denies the set_volume command without any pre-configured scope. + +
+ +`audio:allow-stop` + + + +Enables the stop command without any pre-configured scope. + +
+ +`audio:deny-stop` + + + +Denies the stop command without any pre-configured scope. + +
diff --git a/permissions/default.toml b/permissions/default.toml new file mode 100644 index 0000000..7ed87e5 --- /dev/null +++ b/permissions/default.toml @@ -0,0 +1,15 @@ +[default] +description = "Default permissions for the plugin" +permissions = [ + "allow-load", + "allow-play", + "allow-pause", + "allow-stop", + "allow-seek", + "allow-set-volume", + "allow-set-muted", + "allow-set-playback-rate", + "allow-set-loop", + "allow-get-state", + "allow-is-native", +] diff --git a/permissions/schemas/schema.json b/permissions/schemas/schema.json new file mode 100644 index 0000000..64a6f6d --- /dev/null +++ b/permissions/schemas/schema.json @@ -0,0 +1,438 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "PermissionFile", + "description": "Permission file that can define a default permission, a set of permissions or a list of inlined permissions.", + "type": "object", + "properties": { + "default": { + "description": "The default permission set for the plugin", + "anyOf": [ + { + "$ref": "#/definitions/DefaultPermission" + }, + { + "type": "null" + } + ] + }, + "set": { + "description": "A list of permissions sets defined", + "type": "array", + "items": { + "$ref": "#/definitions/PermissionSet" + } + }, + "permission": { + "description": "A list of inlined permissions", + "default": [], + "type": "array", + "items": { + "$ref": "#/definitions/Permission" + } + } + }, + "definitions": { + "DefaultPermission": { + "description": "The default permission set of the plugin.\n\nWorks similarly to a permission with the \"default\" identifier.", + "type": "object", + "required": [ + "permissions" + ], + "properties": { + "version": { + "description": "The version of the permission.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 1.0 + }, + "description": { + "description": "Human-readable description of what the permission does. Tauri convention is to use `

` headings in markdown content for Tauri documentation generation purposes.", + "type": [ + "string", + "null" + ] + }, + "permissions": { + "description": "All permissions this set contains.", + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "PermissionSet": { + "description": "A set of direct permissions grouped together under a new name.", + "type": "object", + "required": [ + "description", + "identifier", + "permissions" + ], + "properties": { + "identifier": { + "description": "A unique identifier for the permission.", + "type": "string" + }, + "description": { + "description": "Human-readable description of what the permission does.", + "type": "string" + }, + "permissions": { + "description": "All permissions this set contains.", + "type": "array", + "items": { + "$ref": "#/definitions/PermissionKind" + } + } + } + }, + "Permission": { + "description": "Descriptions of explicit privileges of commands.\n\nIt can enable commands to be accessible in the frontend of the application.\n\nIf the scope is defined it can be used to fine grain control the access of individual or multiple commands.", + "type": "object", + "required": [ + "identifier" + ], + "properties": { + "version": { + "description": "The version of the permission.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 1.0 + }, + "identifier": { + "description": "A unique identifier for the permission.", + "type": "string" + }, + "description": { + "description": "Human-readable description of what the permission does. Tauri internal convention is to use `

` headings in markdown content for Tauri documentation generation purposes.", + "type": [ + "string", + "null" + ] + }, + "commands": { + "description": "Allowed or denied commands when using this permission.", + "default": { + "allow": [], + "deny": [] + }, + "allOf": [ + { + "$ref": "#/definitions/Commands" + } + ] + }, + "scope": { + "description": "Allowed or denied scoped when using this permission.", + "allOf": [ + { + "$ref": "#/definitions/Scopes" + } + ] + }, + "platforms": { + "description": "Target platforms this permission applies. By default all platforms are affected by this permission.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Target" + } + } + } + }, + "Commands": { + "description": "Allowed and denied commands inside a permission.\n\nIf two commands clash inside of `allow` and `deny`, it should be denied by default.", + "type": "object", + "properties": { + "allow": { + "description": "Allowed command.", + "default": [], + "type": "array", + "items": { + "type": "string" + } + }, + "deny": { + "description": "Denied command, which takes priority.", + "default": [], + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "Scopes": { + "description": "An argument for fine grained behavior control of Tauri commands.\n\nIt can be of any serde serializable type and is used to allow or prevent certain actions inside a Tauri command. The configured scope is passed to the command and will be enforced by the command implementation.\n\n## Example\n\n```json { \"allow\": [{ \"path\": \"$HOME/**\" }], \"deny\": [{ \"path\": \"$HOME/secret.txt\" }] } ```", + "type": "object", + "properties": { + "allow": { + "description": "Data that defines what is allowed by the scope.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Value" + } + }, + "deny": { + "description": "Data that defines what is denied by the scope. This should be prioritized by validation logic.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Value" + } + } + } + }, + "Value": { + "description": "All supported ACL values.", + "anyOf": [ + { + "description": "Represents a null JSON value.", + "type": "null" + }, + { + "description": "Represents a [`bool`].", + "type": "boolean" + }, + { + "description": "Represents a valid ACL [`Number`].", + "allOf": [ + { + "$ref": "#/definitions/Number" + } + ] + }, + { + "description": "Represents a [`String`].", + "type": "string" + }, + { + "description": "Represents a list of other [`Value`]s.", + "type": "array", + "items": { + "$ref": "#/definitions/Value" + } + }, + { + "description": "Represents a map of [`String`] keys to [`Value`]s.", + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/Value" + } + } + ] + }, + "Number": { + "description": "A valid ACL number.", + "anyOf": [ + { + "description": "Represents an [`i64`].", + "type": "integer", + "format": "int64" + }, + { + "description": "Represents a [`f64`].", + "type": "number", + "format": "double" + } + ] + }, + "Target": { + "description": "Platform target.", + "oneOf": [ + { + "description": "MacOS.", + "type": "string", + "enum": [ + "macOS" + ] + }, + { + "description": "Windows.", + "type": "string", + "enum": [ + "windows" + ] + }, + { + "description": "Linux.", + "type": "string", + "enum": [ + "linux" + ] + }, + { + "description": "Android.", + "type": "string", + "enum": [ + "android" + ] + }, + { + "description": "iOS.", + "type": "string", + "enum": [ + "iOS" + ] + } + ] + }, + "PermissionKind": { + "type": "string", + "oneOf": [ + { + "description": "Enables the get_state command without any pre-configured scope.", + "type": "string", + "const": "allow-get-state", + "markdownDescription": "Enables the get_state command without any pre-configured scope." + }, + { + "description": "Denies the get_state command without any pre-configured scope.", + "type": "string", + "const": "deny-get-state", + "markdownDescription": "Denies the get_state command without any pre-configured scope." + }, + { + "description": "Enables the is_native command without any pre-configured scope.", + "type": "string", + "const": "allow-is-native", + "markdownDescription": "Enables the is_native command without any pre-configured scope." + }, + { + "description": "Denies the is_native command without any pre-configured scope.", + "type": "string", + "const": "deny-is-native", + "markdownDescription": "Denies the is_native command without any pre-configured scope." + }, + { + "description": "Enables the load command without any pre-configured scope.", + "type": "string", + "const": "allow-load", + "markdownDescription": "Enables the load command without any pre-configured scope." + }, + { + "description": "Denies the load command without any pre-configured scope.", + "type": "string", + "const": "deny-load", + "markdownDescription": "Denies the load command without any pre-configured scope." + }, + { + "description": "Enables the pause command without any pre-configured scope.", + "type": "string", + "const": "allow-pause", + "markdownDescription": "Enables the pause command without any pre-configured scope." + }, + { + "description": "Denies the pause command without any pre-configured scope.", + "type": "string", + "const": "deny-pause", + "markdownDescription": "Denies the pause command without any pre-configured scope." + }, + { + "description": "Enables the play command without any pre-configured scope.", + "type": "string", + "const": "allow-play", + "markdownDescription": "Enables the play command without any pre-configured scope." + }, + { + "description": "Denies the play command without any pre-configured scope.", + "type": "string", + "const": "deny-play", + "markdownDescription": "Denies the play command without any pre-configured scope." + }, + { + "description": "Enables the seek command without any pre-configured scope.", + "type": "string", + "const": "allow-seek", + "markdownDescription": "Enables the seek command without any pre-configured scope." + }, + { + "description": "Denies the seek command without any pre-configured scope.", + "type": "string", + "const": "deny-seek", + "markdownDescription": "Denies the seek command without any pre-configured scope." + }, + { + "description": "Enables the set_loop command without any pre-configured scope.", + "type": "string", + "const": "allow-set-loop", + "markdownDescription": "Enables the set_loop command without any pre-configured scope." + }, + { + "description": "Denies the set_loop command without any pre-configured scope.", + "type": "string", + "const": "deny-set-loop", + "markdownDescription": "Denies the set_loop command without any pre-configured scope." + }, + { + "description": "Enables the set_muted command without any pre-configured scope.", + "type": "string", + "const": "allow-set-muted", + "markdownDescription": "Enables the set_muted command without any pre-configured scope." + }, + { + "description": "Denies the set_muted command without any pre-configured scope.", + "type": "string", + "const": "deny-set-muted", + "markdownDescription": "Denies the set_muted command without any pre-configured scope." + }, + { + "description": "Enables the set_playback_rate command without any pre-configured scope.", + "type": "string", + "const": "allow-set-playback-rate", + "markdownDescription": "Enables the set_playback_rate command without any pre-configured scope." + }, + { + "description": "Denies the set_playback_rate command without any pre-configured scope.", + "type": "string", + "const": "deny-set-playback-rate", + "markdownDescription": "Denies the set_playback_rate command without any pre-configured scope." + }, + { + "description": "Enables the set_volume command without any pre-configured scope.", + "type": "string", + "const": "allow-set-volume", + "markdownDescription": "Enables the set_volume command without any pre-configured scope." + }, + { + "description": "Denies the set_volume command without any pre-configured scope.", + "type": "string", + "const": "deny-set-volume", + "markdownDescription": "Denies the set_volume command without any pre-configured scope." + }, + { + "description": "Enables the stop command without any pre-configured scope.", + "type": "string", + "const": "allow-stop", + "markdownDescription": "Enables the stop command without any pre-configured scope." + }, + { + "description": "Denies the stop command without any pre-configured scope.", + "type": "string", + "const": "deny-stop", + "markdownDescription": "Denies the stop command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-load`\n- `allow-play`\n- `allow-pause`\n- `allow-stop`\n- `allow-seek`\n- `allow-set-volume`\n- `allow-set-muted`\n- `allow-set-playback-rate`\n- `allow-set-loop`\n- `allow-get-state`\n- `allow-is-native`", + "type": "string", + "const": "default", + "markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-load`\n- `allow-play`\n- `allow-pause`\n- `allow-stop`\n- `allow-seek`\n- `allow-set-volume`\n- `allow-set-muted`\n- `allow-set-playback-rate`\n- `allow-set-loop`\n- `allow-get-state`\n- `allow-is-native`" + } + ] + } + } +} \ No newline at end of file diff --git a/src/commands.rs b/src/commands.rs new file mode 100644 index 0000000..0f556b0 --- /dev/null +++ b/src/commands.rs @@ -0,0 +1,72 @@ +use tauri::{AppHandle, Runtime, command}; + +use crate::AudioExt; +use crate::error::Result; +use crate::models::{AudioActionResponse, AudioMetadata, PlayerState}; + +#[command] +pub(crate) async fn load( + app: AppHandle, + src: String, + metadata: Option, +) -> Result { + app.audio().load(&src, metadata) +} + +#[command] +pub(crate) async fn play(app: AppHandle) -> Result { + app.audio().play() +} + +#[command] +pub(crate) async fn pause(app: AppHandle) -> Result { + app.audio().pause() +} + +#[command] +pub(crate) async fn stop(app: AppHandle) -> Result { + app.audio().stop() +} + +#[command] +pub(crate) async fn seek( + app: AppHandle, + position: f64, +) -> Result { + app.audio().seek(position) +} + +#[command] +pub(crate) async fn set_volume(app: AppHandle, level: f64) -> Result { + app.audio().set_volume(level) +} + +#[command] +pub(crate) async fn set_muted(app: AppHandle, muted: bool) -> Result { + Ok(app.audio().set_muted(muted)) +} + +#[command] +pub(crate) async fn set_playback_rate( + app: AppHandle, + rate: f64, +) -> Result { + app.audio().set_playback_rate(rate) +} + +#[command] +pub(crate) async fn set_loop(app: AppHandle, looping: bool) -> Result { + Ok(app.audio().set_loop(looping)) +} + +#[command] +pub(crate) async fn get_state(app: AppHandle) -> Result { + Ok(app.audio().get_state()) +} + +#[command] +pub(crate) async fn is_native(_app: AppHandle) -> Result { + // Desktop is not "native" in the mobile plugin sense. When mobile implementations + // are added, this will return `true` on iOS/Android to switch the event transport. + Ok(false) +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..dddb79f --- /dev/null +++ b/src/error.rs @@ -0,0 +1,29 @@ +use serde::{Serialize, ser::Serializer}; + +pub type Result = std::result::Result; + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("Invalid State: {0}")] + InvalidState(String), + + #[error("Invalid Value: {0}")] + InvalidValue(String), + + #[error("Not Loaded")] + NotLoaded, + + #[error(transparent)] + Io(#[from] std::io::Error), +} + +/// Serialize errors as plain strings for the Tauri IPC bridge. +/// The TypeScript layer receives these as rejected promise messages. +impl Serialize for Error { + fn serialize(&self, serializer: S) -> std::result::Result + where + S: Serializer, + { + serializer.serialize_str(self.to_string().as_ref()) + } +} diff --git a/src/lib.rs b/src/lib.rs index 8b13789..f2af503 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1 +1,513 @@ +use std::sync::{Arc, Mutex}; +use tauri::{ + Emitter, Manager, Runtime, + plugin::{Builder, TauriPlugin}, +}; +use tracing::warn; + +mod commands; +mod error; +mod models; + +pub use error::{Error, Result}; +pub use models::{AudioActionResponse, AudioMetadata, PlaybackStatus, PlayerState, TimeUpdate}; + +/// Mock audio player that manages state transitions without actual audio playback. +/// +/// This is the desktop-only mock implementation. When real playback is added, this will +/// be replaced by a platform-specific implementation (e.g. AVAudioPlayer on iOS, +/// MediaPlayer on Android, or a native audio library on Windows). +pub struct AudioPlayer { + state: Mutex, + on_changed: Arc, +} + +impl AudioPlayer { + pub fn new(on_changed: Arc) -> Self { + Self { + state: Mutex::new(PlayerState::default()), + on_changed, + } + } + + pub fn get_state(&self) -> PlayerState { + self.state.lock().unwrap().clone() + } + + /// Applies a mutation to the player state, emits a change event, and returns + /// the resulting state snapshot. + fn update_state(&self, f: impl FnOnce(&mut PlayerState)) -> PlayerState { + let mut state = self.state.lock().unwrap(); + f(&mut state); + let snapshot = state.clone(); + drop(state); + (self.on_changed)(&snapshot); + snapshot + } + + /// Like [`update_state`], but the closure may fail. The change event is only + /// emitted on success; on failure the state is left unchanged. + fn try_update_state( + &self, + f: impl FnOnce(&mut PlayerState) -> Result<()>, + ) -> Result { + let mut state = self.state.lock().unwrap(); + f(&mut state)?; + let snapshot = state.clone(); + drop(state); + (self.on_changed)(&snapshot); + Ok(snapshot) + } + + pub fn load(&self, src: &str, metadata: Option) -> Result { + let meta = metadata.unwrap_or_default(); + let player = self.try_update_state(|s| { + match s.status { + PlaybackStatus::Idle | PlaybackStatus::Ended | PlaybackStatus::Error => {} + _ => { + return Err(Error::InvalidState(format!( + "Cannot load in {:?} state", + s.status + ))); + } + } + + s.status = PlaybackStatus::Ready; + s.src = Some(src.to_string()); + s.title = meta.title.clone(); + s.artist = meta.artist.clone(); + s.artwork = meta.artwork.clone(); + s.current_time = 0.0; + s.duration = 0.0; + s.error = None; + Ok(()) + })?; + + Ok(AudioActionResponse::new(player, PlaybackStatus::Ready)) + } + + pub fn play(&self) -> Result { + let player = self.try_update_state(|s| { + match s.status { + PlaybackStatus::Ready | PlaybackStatus::Paused | PlaybackStatus::Ended => {} + _ => { + return Err(Error::InvalidState(format!( + "Cannot play in {:?} state", + s.status + ))); + } + } + + s.status = PlaybackStatus::Playing; + Ok(()) + })?; + + Ok(AudioActionResponse::new(player, PlaybackStatus::Playing)) + } + + pub fn pause(&self) -> Result { + let player = self.try_update_state(|s| { + match s.status { + PlaybackStatus::Playing => {} + _ => { + return Err(Error::InvalidState(format!( + "Cannot pause in {:?} state", + s.status + ))); + } + } + + s.status = PlaybackStatus::Paused; + Ok(()) + })?; + + Ok(AudioActionResponse::new(player, PlaybackStatus::Paused)) + } + + pub fn stop(&self) -> Result { + let player = self.try_update_state(|s| { + match s.status { + PlaybackStatus::Loading + | PlaybackStatus::Ready + | PlaybackStatus::Playing + | PlaybackStatus::Paused + | PlaybackStatus::Ended => {} + _ => { + return Err(Error::InvalidState(format!( + "Cannot stop in {:?} state", + s.status + ))); + } + } + + // Reset to idle but preserve user settings (volume, muted, rate, loop). + *s = PlayerState { + volume: s.volume, + muted: s.muted, + playback_rate: s.playback_rate, + looping: s.looping, + ..Default::default() + }; + Ok(()) + })?; + + Ok(AudioActionResponse::new(player, PlaybackStatus::Idle)) + } + + pub fn seek(&self, position: f64) -> Result { + if !position.is_finite() { + return Err(Error::InvalidValue(format!( + "Seek position must be finite, got {position}" + ))); + } + + let player = self.try_update_state(|s| { + match s.status { + PlaybackStatus::Ready + | PlaybackStatus::Playing + | PlaybackStatus::Paused + | PlaybackStatus::Ended => {} + _ => { + return Err(Error::InvalidState(format!( + "Cannot seek in {:?} state", + s.status + ))); + } + } + + s.current_time = position.max(0.0); + Ok(()) + })?; + + // Seek preserves the current status. + let expected = player.status; + Ok(AudioActionResponse::new(player, expected)) + } + + pub fn set_volume(&self, level: f64) -> Result { + if !level.is_finite() { + return Err(Error::InvalidValue(format!( + "Volume must be finite, got {level}" + ))); + } + + Ok(self.update_state(|s| { + s.volume = level.clamp(0.0, 1.0); + })) + } + + pub fn set_muted(&self, muted: bool) -> PlayerState { + self.update_state(|s| { + s.muted = muted; + }) + } + + pub fn set_playback_rate(&self, rate: f64) -> Result { + if !rate.is_finite() { + return Err(Error::InvalidValue(format!( + "Playback rate must be finite, got {rate}" + ))); + } + + Ok(self.update_state(|s| { + s.playback_rate = rate.clamp(0.25, 4.0); + })) + } + + pub fn set_loop(&self, looping: bool) -> PlayerState { + self.update_state(|s| { + s.looping = looping; + }) + } +} + +/// Extensions to [`tauri::App`], [`tauri::AppHandle`] and [`tauri::Window`] to access +/// the audio player APIs. +pub trait AudioExt { + fn audio(&self) -> &AudioPlayer; +} + +impl> AudioExt for T { + fn audio(&self) -> &AudioPlayer { + self.state::().inner() + } +} + +/// Initializes the audio plugin with mock playback support. +pub fn init() -> TauriPlugin { + Builder::new("audio") + .invoke_handler(tauri::generate_handler![ + commands::load, + commands::play, + commands::pause, + commands::stop, + commands::seek, + commands::set_volume, + commands::set_muted, + commands::set_playback_rate, + commands::set_loop, + commands::get_state, + commands::is_native, + ]) + .setup(|app, _api| { + let app_handle = app.app_handle().clone(); + let player = AudioPlayer::new(Arc::new(move |state| { + if let Err(e) = app_handle.emit("tauri-plugin-audio:state-changed", state) { + warn!("Failed to emit state-changed event: {}", e); + } + })); + app.manage(player); + Ok(()) + }) + .build() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_player() -> AudioPlayer { + AudioPlayer::new(Arc::new(|_| {})) + } + + #[test] + fn initial_state_is_idle() { + let player = test_player(); + assert_eq!(player.get_state().status, PlaybackStatus::Idle); + assert_eq!(player.get_state().volume, 1.0); + assert!(!player.get_state().muted); + } + + #[test] + fn load_transitions_idle_to_ready() { + let player = test_player(); + let resp = player + .load( + "test.mp3", + Some(AudioMetadata { + title: Some("Test Song".to_string()), + artist: Some("Test Artist".to_string()), + artwork: None, + }), + ) + .unwrap(); + + assert_eq!(resp.player.status, PlaybackStatus::Ready); + assert_eq!(resp.player.src.as_deref(), Some("test.mp3")); + assert_eq!(resp.player.title.as_deref(), Some("Test Song")); + assert_eq!(resp.player.artist.as_deref(), Some("Test Artist")); + assert!(resp.is_expected_status); + } + + #[test] + fn play_transitions_ready_to_playing() { + let player = test_player(); + player.load("test.mp3", None).unwrap(); + let resp = player.play().unwrap(); + + assert_eq!(resp.player.status, PlaybackStatus::Playing); + assert!(resp.is_expected_status); + } + + #[test] + fn pause_transitions_playing_to_paused() { + let player = test_player(); + player.load("test.mp3", None).unwrap(); + player.play().unwrap(); + let resp = player.pause().unwrap(); + + assert_eq!(resp.player.status, PlaybackStatus::Paused); + assert!(resp.is_expected_status); + } + + #[test] + fn resume_transitions_paused_to_playing() { + let player = test_player(); + player.load("test.mp3", None).unwrap(); + player.play().unwrap(); + player.pause().unwrap(); + let resp = player.play().unwrap(); + + assert_eq!(resp.player.status, PlaybackStatus::Playing); + assert!(resp.is_expected_status); + } + + #[test] + fn stop_resets_to_idle_preserving_settings() { + let player = test_player(); + player.set_volume(0.5).unwrap(); + player.set_muted(true); + player.set_playback_rate(1.5).unwrap(); + player.set_loop(true); + player.load("test.mp3", None).unwrap(); + player.play().unwrap(); + let resp = player.stop().unwrap(); + + assert_eq!(resp.player.status, PlaybackStatus::Idle); + assert_eq!(resp.player.volume, 0.5); + assert!(resp.player.muted); + assert_eq!(resp.player.playback_rate, 1.5); + assert!(resp.player.looping); + assert!(resp.player.src.is_none()); + assert!(resp.is_expected_status); + } + + #[test] + fn seek_preserves_current_status() { + let player = test_player(); + player.load("test.mp3", None).unwrap(); + player.play().unwrap(); + let resp = player.seek(30.0).unwrap(); + + assert_eq!(resp.player.status, PlaybackStatus::Playing); + assert_eq!(resp.player.current_time, 30.0); + assert!(resp.is_expected_status); + } + + #[test] + fn seek_clamps_negative_to_zero() { + let player = test_player(); + player.load("test.mp3", None).unwrap(); + let resp = player.seek(-5.0).unwrap(); + + assert_eq!(resp.player.current_time, 0.0); + } + + #[test] + fn cannot_play_in_idle_state() { + let player = test_player(); + assert!(player.play().is_err()); + } + + #[test] + fn cannot_pause_in_idle_state() { + let player = test_player(); + assert!(player.pause().is_err()); + } + + #[test] + fn cannot_stop_in_idle_state() { + let player = test_player(); + assert!(player.stop().is_err()); + } + + #[test] + fn cannot_seek_in_idle_state() { + let player = test_player(); + assert!(player.seek(10.0).is_err()); + } + + #[test] + fn cannot_load_while_playing() { + let player = test_player(); + player.load("test.mp3", None).unwrap(); + player.play().unwrap(); + assert!(player.load("other.mp3", None).is_err()); + } + + #[test] + fn can_load_after_ended() { + let player = test_player(); + // Simulate ended state by loading, playing, then manually setting ended. + player.load("test.mp3", None).unwrap(); + player.state.lock().unwrap().status = PlaybackStatus::Ended; + let resp = player.load("other.mp3", None).unwrap(); + + assert_eq!(resp.player.status, PlaybackStatus::Ready); + assert_eq!(resp.player.src.as_deref(), Some("other.mp3")); + } + + #[test] + fn can_load_after_error() { + let player = test_player(); + player.state.lock().unwrap().status = PlaybackStatus::Error; + let resp = player.load("test.mp3", None).unwrap(); + + assert_eq!(resp.player.status, PlaybackStatus::Ready); + } + + #[test] + fn set_volume_clamps_to_range() { + let player = test_player(); + + let state = player.set_volume(1.5).unwrap(); + assert_eq!(state.volume, 1.0); + + let state = player.set_volume(-0.5).unwrap(); + assert_eq!(state.volume, 0.0); + + let state = player.set_volume(0.7).unwrap(); + assert_eq!(state.volume, 0.7); + } + + #[test] + fn set_volume_rejects_nan() { + let player = test_player(); + assert!(player.set_volume(f64::NAN).is_err()); + assert!(player.set_volume(f64::INFINITY).is_err()); + } + + #[test] + fn set_muted_updates_state() { + let player = test_player(); + let state = player.set_muted(true); + assert!(state.muted); + } + + #[test] + fn set_playback_rate_clamps_to_range() { + let player = test_player(); + + let state = player.set_playback_rate(2.0).unwrap(); + assert_eq!(state.playback_rate, 2.0); + + let state = player.set_playback_rate(0.0).unwrap(); + assert_eq!(state.playback_rate, 0.25); + + let state = player.set_playback_rate(-1.0).unwrap(); + assert_eq!(state.playback_rate, 0.25); + + let state = player.set_playback_rate(10.0).unwrap(); + assert_eq!(state.playback_rate, 4.0); + } + + #[test] + fn set_playback_rate_rejects_nan() { + let player = test_player(); + assert!(player.set_playback_rate(f64::NAN).is_err()); + assert!(player.set_playback_rate(f64::INFINITY).is_err()); + } + + #[test] + fn seek_rejects_nan() { + let player = test_player(); + player.load("test.mp3", None).unwrap(); + assert!(player.seek(f64::NAN).is_err()); + assert!(player.seek(f64::INFINITY).is_err()); + } + + #[test] + fn cannot_stop_in_error_state() { + let player = test_player(); + player.state.lock().unwrap().status = PlaybackStatus::Error; + assert!(player.stop().is_err()); + } + + #[test] + fn set_loop_updates_state() { + let player = test_player(); + let state = player.set_loop(true); + assert!(state.looping); + } + + #[test] + fn play_from_ended_state() { + let player = test_player(); + player.load("test.mp3", None).unwrap(); + player.state.lock().unwrap().status = PlaybackStatus::Ended; + let resp = player.play().unwrap(); + + assert_eq!(resp.player.status, PlaybackStatus::Playing); + } +} diff --git a/src/models.rs b/src/models.rs new file mode 100644 index 0000000..7f03159 --- /dev/null +++ b/src/models.rs @@ -0,0 +1,121 @@ +use serde::{Deserialize, Serialize}; + +/// Represents the current playback status of the audio player. +/// +/// Modeled after common media player states (inspired by Vidstack's player state model), +/// adapted for a headless native audio context. +#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum PlaybackStatus { + /// No audio source is loaded. + #[default] + Idle, + + /// An audio source is being loaded. Reserved for the real implementation + /// where loading is asynchronous. The mock transitions directly from + /// Idle to Ready. + Loading, + + /// Audio source is loaded and ready to play. + Ready, + + /// Audio is currently playing. + Playing, + + /// Audio playback is paused. + Paused, + + /// Audio playback has reached the end. + Ended, + + /// An error occurred during loading or playback. + Error, +} + +/// Metadata for the audio source, used for OS transport control integration +/// (lock screen, notification shade, headphone controls, etc.). +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AudioMetadata { + pub title: Option, + pub artist: Option, + pub artwork: Option, +} + +/// The complete state of the audio player at a point in time. +/// +/// Serialized to the TypeScript layer via Tauri's IPC bridge. Field names use camelCase +/// to match JavaScript conventions (e.g. `current_time` becomes `currentTime`). +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PlayerState { + pub status: PlaybackStatus, + pub src: Option, + pub title: Option, + pub artist: Option, + pub artwork: Option, + pub current_time: f64, + pub duration: f64, + pub volume: f64, + pub muted: bool, + pub playback_rate: f64, + /// Whether the audio should loop when it reaches the end. + /// Named `looping` in Rust (since `loop` is a keyword), serialized as `"loop"` in JSON. + #[serde(rename = "loop")] + pub looping: bool, + pub error: Option, +} + +impl Default for PlayerState { + fn default() -> Self { + Self { + status: PlaybackStatus::Idle, + src: None, + title: None, + artist: None, + artwork: None, + current_time: 0.0, + duration: 0.0, + volume: 1.0, + muted: false, + playback_rate: 1.0, + looping: false, + error: None, + } + } +} + +/// Lightweight time update payload emitted at high frequency during playback. +/// +/// Separated from [`PlayerState`] to avoid serializing the full state on every +/// tick (typically every 250ms). The real implementation emits this via the +/// `tauri-plugin-audio:time-update` event. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TimeUpdate { + pub current_time: f64, + pub duration: f64, +} + +/// Response from a transport action (load, play, pause, stop, seek). +/// +/// Wraps the resulting [`PlayerState`] with status-expectation metadata so the +/// TypeScript layer can detect unexpected state transitions. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AudioActionResponse { + pub player: PlayerState, + pub expected_status: PlaybackStatus, + pub is_expected_status: bool, +} + +impl AudioActionResponse { + pub fn new(player: PlayerState, expected_status: PlaybackStatus) -> Self { + let is_expected_status = player.status == expected_status; + Self { + player, + expected_status, + is_expected_status, + } + } +}