From dd2db31b27b6319466a9fc3ece7091e33ead2478 Mon Sep 17 00:00:00 2001 From: Mikko Ahlroth Date: Sun, 28 Jan 2024 22:29:04 +0200 Subject: [PATCH] Add implementation for the rest of media session API --- src/elekf/web/authed_view.gleam | 6 +- src/elekf/web/components/player.gleam | 93 ++++++++++++++++++++----- src/media_ffi.mjs | 85 ++++++++++++++++++++++ src/plinth/browser/media/action.gleam | 56 +++++++++++++++ src/plinth/browser/media/position.gleam | 73 +++++++++++++++++++ src/plinth/browser/media/session.gleam | 26 ++++++- 6 files changed, 318 insertions(+), 21 deletions(-) create mode 100644 src/plinth/browser/media/action.gleam create mode 100644 src/plinth/browser/media/position.gleam diff --git a/src/elekf/web/authed_view.gleam b/src/elekf/web/authed_view.gleam index a0a6e78..6a6a291 100644 --- a/src/elekf/web/authed_view.gleam +++ b/src/elekf/web/authed_view.gleam @@ -276,8 +276,8 @@ pub fn view(model: Model) { fn handle_start_play(model: Model, queue: PlayQueue, position: Int) { let assert Ok(#(track_id, track)) = list.at(queue, position) - let player_model = case model.play_status { - HasTracks(PlayInfo(player: p, ..)) -> p + let #(player_model, maybe_init_effect) = case model.play_status { + HasTracks(PlayInfo(player: p, ..)) -> #(p, effect.none()) NoTracks -> { let assert option.Some(settings) = model.settings player.init( @@ -307,7 +307,7 @@ fn handle_start_play(model: Model, queue: PlayQueue, position: Int) { new_model, Tracking(0.0, 0.0), )), - e, + effect.batch([effect.map(maybe_init_effect, PlayerMsg), e]), ) } diff --git a/src/elekf/web/components/player.gleam b/src/elekf/web/components/player.gleam index 4df339a..f01deca 100644 --- a/src/elekf/web/components/player.gleam +++ b/src/elekf/web/components/player.gleam @@ -3,6 +3,8 @@ import gleam/uri import gleam/int import gleam/float +import gleam/option +import gleam/io import lustre/element.{text} import lustre/element/html.{audio, div, input, p} import lustre/attribute @@ -10,6 +12,8 @@ import lustre/event import lustre/effect import plinth/browser/media/metadata import plinth/browser/media/session +import plinth/browser/media/action +import plinth/browser/media/position import elektrofoni import elekf/web/common import elekf/web/components/icon.{Alt, icon} @@ -25,6 +29,8 @@ import ibroadcast/authed_request.{type RequestConfig} import ibroadcast/streaming import ibroadcast/artwork +const default_seek_offset = 10.0 + pub type PlayState { Playing Paused @@ -61,6 +67,8 @@ pub type Msg { EndUserSkip PositionSelected(Int) PositionChanged(Int) + SeekBackward(Float) + SeekForward(Float) } pub fn init( @@ -73,21 +81,44 @@ pub fn init( let artist = library.assert_artist(library, track.artist_id) let album = library.assert_album(library, track.album_id) + let action_effect = + effect.from(fn(dispatch) { + register_action_handler(action.NextTrack, NextTrack, dispatch) + register_action_handler(action.PreviousTrack, PrevTrack, dispatch) + register_action_handler(action.Play, Play, dispatch) + register_action_handler(action.Pause, Pause, dispatch) + session.set_action_handler(action.SeekBackward, fn(data) { + let seek_amount = option.unwrap(data.seek_offset, default_seek_offset) + dispatch(SeekBackward(seek_amount)) + }) + session.set_action_handler(action.SeekForward, fn(data) { + let seek_amount = option.unwrap(data.seek_offset, default_seek_offset) + dispatch(SeekForward(seek_amount)) + }) + session.set_action_handler(action.SeekTo, fn(data) { + let seek_to = option.unwrap(data.seek_time, 0.0) + dispatch(PositionSelected(float.round(seek_to))) + }) + }) + start_play(settings, artist, album, track) - Model( - settings, - library, - track_id, - track, - artist, - album, - form_url(track_id, track, settings, request_config), - 0, - Playing, - False, - request_config, - False, + #( + Model( + settings, + library, + track_id, + track, + artist, + album, + form_url(track_id, track, settings, request_config), + 0, + Playing, + False, + request_config, + False, + ), + action_effect, ) } @@ -134,6 +165,21 @@ pub fn update(model: Model, msg) { let pos = float.truncate(time) set_track_to(pos) + case + position.make_position_state( + position.Finite(int.to_float(model.track.length)), + option.None, + option.Some(time), + ) + { + Ok(state) -> session.set_position_state(state) + Error(_) -> { + io.println("Unable to set position state") + io.debug(#(model.track.length, 1.0, time)) + Nil + } + } + #(Model(..model, position: pos), effect.none()) } } @@ -151,6 +197,14 @@ pub fn update(model: Model, msg) { PositionChanged(pos) -> { #(Model(..model, position: pos), effect.none()) } + SeekBackward(amount) -> { + let new_pos = float.round(current_time() -. amount) + update(model, PositionSelected(new_pos)) + } + SeekForward(amount) -> { + let new_pos = float.round(current_time() +. amount) + update(model, PositionSelected(new_pos)) + } } } @@ -236,8 +290,7 @@ fn form_url( request_config, settings.streaming_server, elektrofoni.bitrate, - date.unix_now() - + elektrofoni.track_expiry_length, + date.unix_now() + elektrofoni.track_expiry_length, ) } @@ -288,7 +341,15 @@ fn start_play( ), ]) - session.set(metadata) + session.set_metadata(metadata) +} + +fn register_action_handler( + action: action.Action, + msg: Msg, + dispatch: fn(Msg) -> Nil, +) { + session.set_action_handler(action, fn(_) { dispatch(msg) }) } @external(javascript, "../../../player_ffi.mjs", "play") diff --git a/src/media_ffi.mjs b/src/media_ffi.mjs index 9accdf3..8d79065 100644 --- a/src/media_ffi.mjs +++ b/src/media_ffi.mjs @@ -1,3 +1,45 @@ +import { Some, None } from "../gleam_stdlib/gleam/option.mjs"; +import { Finite } from "./plinth/browser/media/position.mjs"; +import { + HangUp, + NextSlide, + NextTrack, + Pause, + Play, + PreviousSlide, + PreviousTrack, + SeekBackward, + SeekForward, + SeekTo, + SkipAd, + Stop, + ToggleCamera, + ToggleMicrophone, + ActionData, +} from "./plinth/browser/media/action.mjs"; + +const ACTION_TO_STR = new Map([ + [HangUp, "hangup"], + [NextSlide, "nextslide"], + [NextTrack, "nexttrack"], + [Pause, "pause"], + [Play, "play"], + [PreviousSlide, "previousslide"], + [PreviousTrack, "previoustrack"], + [SeekBackward, "seekbackward"], + [SeekForward, "seekforward"], + [SeekTo, "seekto"], + [SkipAd, "skipad"], + [Stop, "stop"], + [ToggleCamera, "togglecamera"], + [ToggleMicrophone, "togglemicrophone"], +]); + +const STR_TO_ACTION = new Map(); +for (const [k, v] of ACTION_TO_STR) { + STR_TO_ACTION.set(v, k); +} + export function get() { return navigator.mediaSession.metadata ?? new globalThis.MediaMetadata(); } @@ -11,6 +53,49 @@ export function set(metadata) { }); } +export function setPositionState(new_state) { + // If duration doesn't exist, this must be a ClearState (can't import it + // because it's opaque) + if (new_state.duration === undefined) { + navigator.mediaSession.setPositionState(); + } else { + const state = { + duration: + new_state.duration instanceof Finite + ? new_state.duration.seconds + : Infinity, + }; + + if (new_state.playback_rate instanceof Some) { + state.playbackRate = new_state.playback_rate[0]; + } + + if (new_state.position instanceof Some) { + state.position = new_state.position[0]; + } + + navigator.mediaSession.setPositionState(state); + } +} + +export function setActionHandler(action, handler) { + navigator.mediaSession.setActionHandler( + ACTION_TO_STR.get(action.constructor), + (details) => { + const actionConstructor = STR_TO_ACTION.get(details.action); + const action = new actionConstructor(); + const gleamDetails = new ActionData( + action, + details.fastSeek ? new Some(details.fastSeek) : new None(), + details.seekOffset ? new Some(details.seekOffset) : new None(), + details.seekTime ? new Some(details.seekTime) : new None() + ); + + handler(gleamDetails); + } + ); +} + function artwork2Obj(artwork) { return { src: artwork.src, diff --git a/src/plinth/browser/media/action.gleam b/src/plinth/browser/media/action.gleam new file mode 100644 index 0000000..18f46da --- /dev/null +++ b/src/plinth/browser/media/action.gleam @@ -0,0 +1,56 @@ +import gleam/option + +/// Action type that can have a callback registered for it. +pub type Action { + /// End a call. + HangUp + + /// Moves to the next slide, when presenting a slide deck. + NextSlide + + /// Advances playback to the next track. + NextTrack + + /// Pauses playback of the media. + Pause + + /// Begins (or resumes) playback of the media. + Play + + /// Moves to the previous slide, when presenting a slide deck. + PreviousSlide + + /// Moves back to the previous track. + PreviousTrack + + /// Seeks backward through the media from the current position. + SeekBackward + + /// Seeks forward from the current position through the media. + SeekForward + + /// Moves the playback position to the specified time within the media. + SeekTo + + /// Skips past the currently playing advertisement or commercial. + SkipAd + + /// Halts playback entirely. + Stop + + /// Turn the user's active camera on or off. + ToggleCamera + + /// Mute or unmute the user's microphone. + ToggleMicrophone +} + +/// Data sent to action handler callback. +pub type ActionData { + ActionData( + action: Action, + fast_seek: option.Option(Bool), + seek_offset: option.Option(Float), + seek_time: option.Option(Float), + ) +} diff --git a/src/plinth/browser/media/position.gleam b/src/plinth/browser/media/position.gleam new file mode 100644 index 0000000..c62c926 --- /dev/null +++ b/src/plinth/browser/media/position.gleam @@ -0,0 +1,73 @@ +import gleam/option +import gleam/result + +/// Total duration of the played media. +pub type Duration { + /// The media has a finite duration, in seconds. + Finite(seconds: Float) + + /// The media has no finite duration (e.g. a stream). + Infinite +} + +/// The position, duration, and playback rate of the current media. Only +/// duration is mandatory. +pub opaque type PositionState { + PositionState( + duration: Duration, + playback_rate: option.Option(Float), + position: option.Option(Float), + ) + ClearState +} + +/// Make a position state with the given information. +/// +/// There are some rules with the data: +/// * The duration must be bigger than 0. +/// * The position, if given, must be between 0 and duration. +/// * The playback rate can't be 0, but it can be negative. +pub fn make_position_state( + duration: Duration, + playback_rate: option.Option(Float), + position: option.Option(Float), +) -> Result(PositionState, Nil) { + use playback_rate <- result.try(parse_playback_rate(playback_rate)) + use duration <- result.try(parse_duration(duration)) + use position <- result.map(parse_position(position, duration)) + + PositionState( + duration: duration, + playback_rate: playback_rate, + position: position, + ) +} + +/// Make a position state that clears the current information when set. +pub fn make_clear_position_state() -> PositionState { + ClearState +} + +fn parse_position(position, duration) { + case position, duration { + option.None, _ -> Ok(position) + option.Some(val), Infinite if val >=. 0.0 -> Ok(position) + option.Some(val), Finite(dur) if val >=. 0.0 && val <=. dur -> Ok(position) + _otherwise, _ -> Error(Nil) + } +} + +fn parse_duration(duration) { + case duration { + Infinite -> Ok(duration) + Finite(dur) if dur >=. 0.0 -> Ok(duration) + _otherwise -> Error(Nil) + } +} + +fn parse_playback_rate(playback_rate) { + case playback_rate { + option.Some(0.0) -> Error(Nil) + _otherwise -> Ok(playback_rate) + } +} diff --git a/src/plinth/browser/media/session.gleam b/src/plinth/browser/media/session.gleam index 30fc9d1..cb6e8a2 100644 --- a/src/plinth/browser/media/session.gleam +++ b/src/plinth/browser/media/session.gleam @@ -1,7 +1,29 @@ +//// Bindings to the MediaSession API. +//// +//// The MediaSession API can be used to show and control details of currently +//// playing media in an OS specific dialog, such as a device lockscreen. It +//// also allows controlling the media using device interfaces such as media +//// buttons. +//// +//// https://developer.mozilla.org/en-US/docs/Web/API/MediaSession + import plinth/browser/media/metadata.{type MediaMetadata} +import plinth/browser/media/action.{type Action, type ActionData} +import plinth/browser/media/position.{type PositionState} +/// Get the current media's metadata. @external(javascript, "../../../media_ffi.mjs", "get") -pub fn get() -> MediaMetadata +pub fn get_metadata() -> MediaMetadata +/// Set the current media's metadata. @external(javascript, "../../../media_ffi.mjs", "set") -pub fn set(metadata: MediaMetadata) -> Nil +pub fn set_metadata(metadata: MediaMetadata) -> Nil + +/// Set the current position state. +@external(javascript, "../../../media_ffi.mjs", "setPositionState") +pub fn set_position_state(new_state: PositionState) -> Nil + +/// Register an action handler to be called when the user triggers some action +/// in the UI or physical controls. +@external(javascript, "../../../media_ffi.mjs", "setActionHandler") +pub fn set_action_handler(action: Action, handler: fn(ActionData) -> Nil) -> Nil