Add implementation for the rest of media session API
This commit is contained in:
parent
6f4cea8614
commit
dd2db31b27
6 changed files with 318 additions and 21 deletions
|
@ -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]),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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,
|
||||
|
|
56
src/plinth/browser/media/action.gleam
Normal file
56
src/plinth/browser/media/action.gleam
Normal 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),
|
||||
)
|
||||
}
|
73
src/plinth/browser/media/position.gleam
Normal file
73
src/plinth/browser/media/position.gleam
Normal 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)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
Loading…
Reference in a new issue