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) {
|
fn handle_start_play(model: Model, queue: PlayQueue, position: Int) {
|
||||||
let assert Ok(#(track_id, track)) = list.at(queue, position)
|
let assert Ok(#(track_id, track)) = list.at(queue, position)
|
||||||
|
|
||||||
let player_model = case model.play_status {
|
let #(player_model, maybe_init_effect) = case model.play_status {
|
||||||
HasTracks(PlayInfo(player: p, ..)) -> p
|
HasTracks(PlayInfo(player: p, ..)) -> #(p, effect.none())
|
||||||
NoTracks -> {
|
NoTracks -> {
|
||||||
let assert option.Some(settings) = model.settings
|
let assert option.Some(settings) = model.settings
|
||||||
player.init(
|
player.init(
|
||||||
|
@ -307,7 +307,7 @@ fn handle_start_play(model: Model, queue: PlayQueue, position: Int) {
|
||||||
new_model,
|
new_model,
|
||||||
Tracking(0.0, 0.0),
|
Tracking(0.0, 0.0),
|
||||||
)),
|
)),
|
||||||
e,
|
effect.batch([effect.map(maybe_init_effect, PlayerMsg), e]),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -3,6 +3,8 @@
|
||||||
import gleam/uri
|
import gleam/uri
|
||||||
import gleam/int
|
import gleam/int
|
||||||
import gleam/float
|
import gleam/float
|
||||||
|
import gleam/option
|
||||||
|
import gleam/io
|
||||||
import lustre/element.{text}
|
import lustre/element.{text}
|
||||||
import lustre/element/html.{audio, div, input, p}
|
import lustre/element/html.{audio, div, input, p}
|
||||||
import lustre/attribute
|
import lustre/attribute
|
||||||
|
@ -10,6 +12,8 @@ import lustre/event
|
||||||
import lustre/effect
|
import lustre/effect
|
||||||
import plinth/browser/media/metadata
|
import plinth/browser/media/metadata
|
||||||
import plinth/browser/media/session
|
import plinth/browser/media/session
|
||||||
|
import plinth/browser/media/action
|
||||||
|
import plinth/browser/media/position
|
||||||
import elektrofoni
|
import elektrofoni
|
||||||
import elekf/web/common
|
import elekf/web/common
|
||||||
import elekf/web/components/icon.{Alt, icon}
|
import elekf/web/components/icon.{Alt, icon}
|
||||||
|
@ -25,6 +29,8 @@ import ibroadcast/authed_request.{type RequestConfig}
|
||||||
import ibroadcast/streaming
|
import ibroadcast/streaming
|
||||||
import ibroadcast/artwork
|
import ibroadcast/artwork
|
||||||
|
|
||||||
|
const default_seek_offset = 10.0
|
||||||
|
|
||||||
pub type PlayState {
|
pub type PlayState {
|
||||||
Playing
|
Playing
|
||||||
Paused
|
Paused
|
||||||
|
@ -61,6 +67,8 @@ pub type Msg {
|
||||||
EndUserSkip
|
EndUserSkip
|
||||||
PositionSelected(Int)
|
PositionSelected(Int)
|
||||||
PositionChanged(Int)
|
PositionChanged(Int)
|
||||||
|
SeekBackward(Float)
|
||||||
|
SeekForward(Float)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn init(
|
pub fn init(
|
||||||
|
@ -73,21 +81,44 @@ pub fn init(
|
||||||
let artist = library.assert_artist(library, track.artist_id)
|
let artist = library.assert_artist(library, track.artist_id)
|
||||||
let album = library.assert_album(library, track.album_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)
|
start_play(settings, artist, album, track)
|
||||||
|
|
||||||
Model(
|
#(
|
||||||
settings,
|
Model(
|
||||||
library,
|
settings,
|
||||||
track_id,
|
library,
|
||||||
track,
|
track_id,
|
||||||
artist,
|
track,
|
||||||
album,
|
artist,
|
||||||
form_url(track_id, track, settings, request_config),
|
album,
|
||||||
0,
|
form_url(track_id, track, settings, request_config),
|
||||||
Playing,
|
0,
|
||||||
False,
|
Playing,
|
||||||
request_config,
|
False,
|
||||||
False,
|
request_config,
|
||||||
|
False,
|
||||||
|
),
|
||||||
|
action_effect,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -134,6 +165,21 @@ pub fn update(model: Model, msg) {
|
||||||
let pos = float.truncate(time)
|
let pos = float.truncate(time)
|
||||||
set_track_to(pos)
|
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())
|
#(Model(..model, position: pos), effect.none())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -151,6 +197,14 @@ pub fn update(model: Model, msg) {
|
||||||
PositionChanged(pos) -> {
|
PositionChanged(pos) -> {
|
||||||
#(Model(..model, position: pos), effect.none())
|
#(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,
|
request_config,
|
||||||
settings.streaming_server,
|
settings.streaming_server,
|
||||||
elektrofoni.bitrate,
|
elektrofoni.bitrate,
|
||||||
date.unix_now()
|
date.unix_now() + elektrofoni.track_expiry_length,
|
||||||
+ 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")
|
@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() {
|
export function get() {
|
||||||
return navigator.mediaSession.metadata ?? new globalThis.MediaMetadata();
|
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) {
|
function artwork2Obj(artwork) {
|
||||||
return {
|
return {
|
||||||
src: artwork.src,
|
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/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")
|
@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")
|
@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