Add implementation for the rest of media session API

This commit is contained in:
Mikko Ahlroth 2024-01-28 22:29:04 +02:00
parent 6f4cea8614
commit dd2db31b27
6 changed files with 318 additions and 21 deletions

View file

@ -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]),
)
}

View file

@ -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")

View file

@ -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,

View file

@ -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),
)
}

View file

@ -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)
}
}

View file

@ -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