use std::{
cell::RefCell,
fmt,
hash::{Hash, Hasher},
io::{self, Read, Seek},
path::{Path, PathBuf},
rc::Rc,
sync::Arc,
time::Duration,
};
use thiserror::Error;
const WEB_MEDIA_UNSUPPORTED: &str = "native media decoding and audio output are unavailable on WebAssembly; use Kael's browser media element route";
trait MediaReadSeek: Read + Seek + Send + Sync {}
impl<T> MediaReadSeek for T where T: Read + Seek + Send + Sync {}
type MediaReaderFactory = dyn Fn() -> io::Result<Box<dyn MediaReadSeek>> + Send + Sync;
#[doc(hidden)]
pub struct ReaderMediaSource {
key: Arc<str>,
_open: Arc<MediaReaderFactory>,
}
#[doc(hidden)]
pub struct BytesMediaSource {
bytes: Arc<[u8]>,
}
#[derive(Clone)]
pub enum MediaSource {
File(PathBuf),
Url(Arc<str>),
Bytes(Arc<BytesMediaSource>),
Reader(Arc<ReaderMediaSource>),
}
impl MediaSource {
pub fn file(path: impl Into<PathBuf>) -> Self {
Self::File(path.into())
}
pub fn url(url: impl Into<Arc<str>>) -> Self {
Self::Url(url.into())
}
pub fn bytes(bytes: impl Into<Arc<[u8]>>) -> Self {
Self::Bytes(Arc::new(BytesMediaSource {
bytes: bytes.into(),
}))
}
pub fn reader<R>(
key: impl Into<Arc<str>>,
open: impl Fn() -> io::Result<R> + Send + Sync + 'static,
) -> Self
where
R: Read + Seek + Send + Sync + 'static,
{
let open =
Arc::new(move || open().map(|reader| -> Box<dyn MediaReadSeek> { Box::new(reader) }));
Self::Reader(Arc::new(ReaderMediaSource {
key: key.into(),
_open: open,
}))
}
pub fn from_static_bytes(bytes: &'static [u8]) -> Self {
Self::bytes(Arc::<[u8]>::from(bytes))
}
pub fn byte_data(&self) -> Option<&[u8]> {
match self {
Self::Bytes(source) => Some(source.bytes.as_ref()),
_ => None,
}
}
pub fn reader_key(&self) -> Option<&str> {
match self {
Self::Reader(source) => Some(&source.key),
_ => None,
}
}
}
impl From<PathBuf> for MediaSource {
fn from(value: PathBuf) -> Self {
Self::File(value)
}
}
impl From<&Path> for MediaSource {
fn from(value: &Path) -> Self {
Self::File(value.to_path_buf())
}
}
impl From<Arc<[u8]>> for MediaSource {
fn from(value: Arc<[u8]>) -> Self {
Self::bytes(value)
}
}
impl From<Arc<str>> for MediaSource {
fn from(value: Arc<str>) -> Self {
Self::Url(value)
}
}
impl From<Vec<u8>> for MediaSource {
fn from(value: Vec<u8>) -> Self {
Self::bytes(value)
}
}
impl From<&'static [u8]> for MediaSource {
fn from(value: &'static [u8]) -> Self {
Self::from_static_bytes(value)
}
}
impl fmt::Debug for MediaSource {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::File(path) => f.debug_tuple("File").field(path).finish(),
Self::Url(_) => f.debug_tuple("Url").field(&"<redacted>").finish(),
Self::Bytes(source) => f
.debug_tuple("Bytes")
.field(&format_args!("{} bytes", source.bytes.len()))
.finish(),
Self::Reader(source) => f.debug_tuple("Reader").field(&source.key).finish(),
}
}
}
impl PartialEq for MediaSource {
fn eq(&self, other: &Self) -> bool {
match (self, other) {
(Self::File(left), Self::File(right)) => left == right,
(Self::Url(left), Self::Url(right)) => left == right,
(Self::Bytes(left), Self::Bytes(right)) => left.bytes == right.bytes,
(Self::Reader(left), Self::Reader(right)) => left.key == right.key,
_ => false,
}
}
}
impl Eq for MediaSource {}
impl Hash for MediaSource {
fn hash<H: Hasher>(&self, state: &mut H) {
std::mem::discriminant(self).hash(state);
match self {
Self::File(path) => path.hash(state),
Self::Url(url) => url.hash(state),
Self::Bytes(source) => source.bytes.hash(state),
Self::Reader(source) => source.key.hash(state),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct VideoMetadata {
pub width: u32,
pub height: u32,
pub duration: Option<Duration>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct VideoFrame {
pub data: Arc<[u8]>,
pub width: u32,
pub height: u32,
pub timestamp: Duration,
}
#[derive(Debug, Error)]
pub enum MediaDecodeError {
#[error("media I/O error: {0}")]
Io(#[from] io::Error),
#[error("unsupported source: {0}")]
UnsupportedSource(String),
#[error("no video stream found")]
NoVideoStream,
#[error("no audio stream found")]
NoAudioStream,
#[error("media decode error: {0}")]
Decode(String),
#[error("media resource limit exceeded: {0}")]
ResourceLimit(String),
}
fn unsupported_decode() -> MediaDecodeError {
MediaDecodeError::UnsupportedSource(WEB_MEDIA_UNSUPPORTED.into())
}
#[derive(Clone, Debug)]
pub struct MediaDecoder {
source: MediaSource,
}
impl MediaDecoder {
pub fn new(source: impl Into<MediaSource>) -> Self {
Self {
source: source.into(),
}
}
pub fn source(&self) -> &MediaSource {
&self.source
}
pub fn video_metadata(&self) -> Result<VideoMetadata, MediaDecodeError> {
Err(unsupported_decode())
}
pub fn decode_video_frames(&self) -> Result<Vec<VideoFrame>, MediaDecodeError> {
Err(unsupported_decode())
}
}
pub struct VideoFrameStream {
source: MediaSource,
}
impl VideoFrameStream {
pub fn new(source: impl Into<MediaSource>) -> Result<Self, MediaDecodeError> {
let _ = source.into();
Err(unsupported_decode())
}
pub fn source(&self) -> &MediaSource {
&self.source
}
pub fn metadata(&self) -> VideoMetadata {
VideoMetadata {
width: 0,
height: 0,
duration: None,
}
}
pub fn restart(&mut self) -> Result<(), MediaDecodeError> {
Err(unsupported_decode())
}
pub fn seek(&mut self, _position: Duration) -> Result<(), MediaDecodeError> {
Err(unsupported_decode())
}
pub fn next_frame(&mut self) -> Result<Option<VideoFrame>, MediaDecodeError> {
Err(unsupported_decode())
}
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum PlaybackState {
Playing,
Paused,
#[default]
Stopped,
}
#[derive(Debug, Error)]
pub enum AudioPlaybackError {
#[error("io error: {0}")]
Io(#[from] io::Error),
#[error("unsupported source: {0}")]
UnsupportedSource(String),
#[error("decoder error: {0}")]
Decoder(String),
#[error(transparent)]
Media(#[from] MediaDecodeError),
#[error("audio output error: {0}")]
Output(String),
}
#[derive(Debug)]
struct AudioHandleState {
source: MediaSource,
volume: f32,
speed: f32,
position: Duration,
state: PlaybackState,
}
#[derive(Clone, Debug)]
pub struct AudioHandle {
state: Rc<RefCell<AudioHandleState>>,
}
impl AudioHandle {
pub fn new(source: impl Into<MediaSource>) -> Self {
Self {
state: Rc::new(RefCell::new(AudioHandleState {
source: source.into(),
volume: 1.0,
speed: 1.0,
position: Duration::ZERO,
state: PlaybackState::Stopped,
})),
}
}
pub fn play(&self) -> Result<(), AudioPlaybackError> {
Err(AudioPlaybackError::UnsupportedSource(
WEB_MEDIA_UNSUPPORTED.into(),
))
}
pub fn pause(&self) {
let mut state = self.state.borrow_mut();
if state.state == PlaybackState::Playing {
state.state = PlaybackState::Paused;
}
}
pub fn stop(&self) {
let mut state = self.state.borrow_mut();
state.position = Duration::ZERO;
state.state = PlaybackState::Stopped;
}
pub fn seek(&self, _position: Duration) -> Result<(), AudioPlaybackError> {
Err(AudioPlaybackError::UnsupportedSource(
WEB_MEDIA_UNSUPPORTED.into(),
))
}
pub fn set_volume(&self, volume: f32) {
self.state.borrow_mut().volume = sanitize_volume(volume);
}
pub fn set_speed(&self, speed: f32) {
self.state.borrow_mut().speed = sanitize_speed(speed);
}
pub fn speed(&self) -> f32 {
self.state.borrow().speed
}
pub fn volume(&self) -> f32 {
self.state.borrow().volume
}
pub fn state(&self) -> PlaybackState {
self.state.borrow().state
}
pub fn position(&self) -> Duration {
self.state.borrow().position
}
pub fn duration(&self) -> Result<Option<Duration>, AudioPlaybackError> {
Err(AudioPlaybackError::UnsupportedSource(
WEB_MEDIA_UNSUPPORTED.into(),
))
}
pub fn source(&self) -> MediaSource {
self.state.borrow().source.clone()
}
}
pub fn probe_audio_duration(
_source: impl Into<MediaSource>,
) -> Result<Option<Duration>, AudioPlaybackError> {
Err(AudioPlaybackError::UnsupportedSource(
WEB_MEDIA_UNSUPPORTED.into(),
))
}
fn sanitize_volume(volume: f32) -> f32 {
if volume.is_finite() {
volume.clamp(0.0, 1.0)
} else {
0.0
}
}
fn sanitize_speed(speed: f32) -> f32 {
if speed.is_finite() {
speed.clamp(0.5, 2.0)
} else {
1.0
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sources_keep_browser_safe_cache_identity() {
let url = MediaSource::url("https://example.com/video.mp4");
assert_eq!(url, url.clone());
assert!(!format!("{url:?}").contains("example.com"));
let bytes = MediaSource::bytes(Arc::<[u8]>::from([1, 2, 3]));
assert_eq!(bytes.byte_data(), Some([1, 2, 3].as_slice()));
}
#[test]
fn native_decode_and_audio_fail_explicitly() {
let source = MediaSource::url("https://example.com/video.mp4");
let decode_error = MediaDecoder::new(source.clone())
.video_metadata()
.expect_err("native frame decoding must not be implied on WebAssembly");
assert!(matches!(
decode_error,
MediaDecodeError::UnsupportedSource(_)
));
let audio_error = AudioHandle::new(source)
.play()
.expect_err("native audio output must not be implied on WebAssembly");
assert!(matches!(
audio_error,
AudioPlaybackError::UnsupportedSource(_)
));
}
#[test]
fn facade_values_are_sanitized_without_native_output() {
let audio = AudioHandle::new(MediaSource::from_static_bytes(&[1]));
audio.set_volume(f32::NAN);
audio.set_speed(f32::INFINITY);
assert_eq!(audio.volume(), 0.0);
assert_eq!(audio.speed(), 1.0);
assert_eq!(audio.state(), PlaybackState::Stopped);
}
}