Cleaning, making generic

This commit is contained in:
Thomas Forgione 2021-06-21 11:05:25 +02:00
parent 51dd1736fd
commit e26971d995
9 changed files with 356 additions and 322 deletions

View File

@ -19,7 +19,7 @@ dev: js/main.js
release: js/main.min.js
js/main.js: src/**
$(ELM) make src/Main.elm --output $(BUILD_DIR)/main.js
$(ELM) make src/Examples/Embed.elm --output $(BUILD_DIR)/main.js
js/main.min.js: js/main.tmp.js
@$(UGLIFYJS) $(BUILD_DIR)/main.tmp.js --compress 'pure_funcs="F2,F3,F4,F5,F6,F7,F8,F9,A2,A3,A4,A5,A6,A7,A8,A9",pure_getters,keep_fargs=false,unsafe_comps,unsafe' | uglifyjs --mangle > $(BUILD_DIR)/main.min.js
@ -28,7 +28,7 @@ js/main.tmp.js: src/**
@$(ELM) make src/Main.elm --optimize --output $(BUILD_DIR)/main.tmp.js
watch:
@$(ELMLIVE) src/Main.elm -p 7000 -d . -- --output $(BUILD_DIR)/main.js
@$(ELMLIVE) src/Examples/Embed.elm -p 7000 -d . -- --output $(BUILD_DIR)/main.js
clean:
@rm -rf $(BUILD_DIR)/{main.js,main.min.js}

View File

@ -4,11 +4,6 @@
<title>twitch.tforgione.fr</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
.filled {
fill: currentColor;
}
</style>
</head>
<body>
<div id="container"></div>

View File

@ -27,16 +27,20 @@ function embed(options) {
options.width = window.innerWidth;
options.height = window.innerHeight;
const app = Elm.Main.init(options);
const app = Elm.Examples.Embed.init(options);
setupApp(app);
}
function setupApp(app) {
let hls;
app.ports.polymnyVideoInit.subscribe(function(arg) {
const video = document.getElementById('video');
const video = document.getElementById(arg[0]);
if (Hls.isSupported()) {
hls = new Hls();
window.hls = hls;
hls.loadSource(arg);
hls.loadSource(arg[1]);
hls.on(Hls.Events.MANIFEST_PARSED, function(event, data) {
const availableQualities = hls.levels.map((l) => l.height);
@ -61,12 +65,12 @@ function embed(options) {
hls.attachMedia(video);
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
video.src = arg;
video.src = arg[1];
}
});
app.ports.polymnyVideoPlayPause.subscribe(function() {
const video = document.getElementById('video');
app.ports.polymnyVideoPlayPause.subscribe(function(arg) {
const video = document.getElementById(arg);
if (video.paused) {
video.play();
} else {
@ -75,30 +79,30 @@ function embed(options) {
});
app.ports.polymnyVideoSeek.subscribe(function(arg) {
const video = document.getElementById('video');
video.currentTime = arg;
const video = document.getElementById(arg[0]);
video.currentTime = arg[1];
});
app.ports.polymnyVideoRequestFullscreen.subscribe(function() {
document.getElementById('full').requestFullscreen();
app.ports.polymnyVideoRequestFullscreen.subscribe(function(arg) {
document.getElementById(arg + '-full').requestFullscreen();
});
app.ports.polymnyVideoExitFullscreen.subscribe(function() {
app.ports.polymnyVideoExitFullscreen.subscribe(function(arg) {
document.exitFullscreen();
});
app.ports.polymnyVideoSetPlaybackRate.subscribe(function(arg) {
const video = document.getElementById('video');
video.playbackRate = arg;
const video = document.getElementById(arg[0]);
video.playbackRate = arg[1];
});
app.ports.polymnyVideoSetQuality.subscribe(function(arg) {
var old = hls.currentLevel;
if (arg.auto) {
if (arg[1].auto) {
hls.currentLevel = -1;
} else {
hls.levels.forEach((level, levelIndex) => {
if (level.height === arg.height) {
if (level.height === arg[1].height) {
hls.currentLevel = levelIndex;
}
});
@ -112,14 +116,14 @@ function embed(options) {
});
app.ports.polymnyVideoSetVolume.subscribe(function(arg) {
const video = document.getElementById('video');
video.volume = arg.volume;
video.muted = arg.muted;
const video = document.getElementById(arg[0]);
video.volume = arg[1].volume;
video.muted = arg[1].muted;
});
app.ports.polymnyVideoSetSubtitleTrack.subscribe(function(arg) {
hls.subtitleDisplay = arg !== -1;
hls.subtitleTrack = arg;
hls.subtitleDisplay = arg[1] !== -1;
hls.subtitleTrack = arg[1];
});
}

View File

@ -1,4 +1,4 @@
module Main exposing (..)
module Examples.Embed exposing (..)
import Browser
import Browser.Events
@ -7,18 +7,16 @@ import Element.Background as Background
import Element.Border as Border
import Element.Font as Font
import Element.Input as Input
import Events
import Html
import Html.Attributes
import Html.Events
import Icons
import Json.Decode as Decode
import Quality
import Simple.Animation as Animation exposing (Animation)
import Simple.Animation.Animated as Animated
import Simple.Animation.Property as P
import Video exposing (Video)
import Views
import Video.Events as Events
import Video.Views as Views
main : Program Decode.Value Model Msg
@ -44,12 +42,6 @@ type alias Model =
}
type Settings
= All
| Speed
| Quality
type Msg
= Noop
| VideoMsg Video.Msg
@ -70,9 +62,12 @@ init flags =
height =
Decode.decodeValue (Decode.field "height" Decode.int) flags
|> Result.withDefault 0
( video, cmd ) =
Video.fromUrl url "video"
in
( { video = Video.fromUrl url, screenSize = ( width, height ) }
, Video.init url
( { video = video, screenSize = ( width, height ) }
, Cmd.map VideoMsg cmd
)

View File

@ -13,13 +13,14 @@ port module Video exposing
)
import Element exposing (Element)
import Icons
import Json.Decode as Decode
import Quality exposing (Quality)
import Video.Icons as Icons
import Video.Quality as Quality exposing (Quality)
type alias Video =
{ url : String
, id : String
, playing : Bool
, position : Float
, duration : Float
@ -43,30 +44,33 @@ type alias Video =
}
fromUrl : String -> Video
fromUrl url =
{ url = url
, playing = False
, position = 0
, duration = 0
, loaded = []
, volume = 1
, muted = False
, isFullscreen = False
, quality = Nothing
, qualities = []
, showBar = True
, animationFrame = 0
, size = ( 0, 0 )
, playbackRate = 1
, settings = All
, showSettings = False
, subtitles = []
, subtitleTrack = Nothing
, showMiniature = Nothing
, showIcon = Nothing
, showIconRequested = Nothing
}
fromUrl : String -> String -> ( Video, Cmd Msg )
fromUrl url id =
( { url = url
, id = id
, playing = False
, position = 0
, duration = 0
, loaded = []
, volume = 1
, muted = False
, isFullscreen = False
, quality = Nothing
, qualities = []
, showBar = True
, animationFrame = 0
, size = ( 0, 0 )
, playbackRate = 1
, settings = All
, showSettings = False
, subtitles = []
, subtitleTrack = Nothing
, showMiniature = Nothing
, showIcon = Nothing
, showIconRequested = Nothing
}
, init id url
)
type Settings
@ -131,7 +135,7 @@ update msg model =
else
Just (Icons.play True)
}
, playPause
, playPause model.id
)
Seek time ->
@ -143,11 +147,11 @@ update msg model =
else
Just (Icons.rewind True)
}
, seek time
, seek model.id time
)
SetPlaybackRate rate ->
( { model | showSettings = False, settings = All }, setPlaybackRate rate )
( { model | showSettings = False, settings = All }, setPlaybackRate model.id rate )
ToggleSettings ->
( { model | showSettings = not model.showSettings }, Cmd.none )
@ -156,16 +160,16 @@ update msg model =
( { model | settings = s }, Cmd.none )
RequestFullscreen ->
( model, requestFullscreen )
( model, requestFullscreen model.id )
ExitFullscreen ->
( model, exitFullscreen )
( model, exitFullscreen model.id )
SetQuality q ->
( { model | showSettings = False, settings = All }, setQuality q )
( { model | showSettings = False, settings = All }, setQuality model.id q )
SetSubtitleTrack t ->
( { model | showSettings = False, settings = All }, setSubtitleTrack t )
( { model | showSettings = False, settings = All }, setSubtitleTrack model.id t )
SetVolume v m ->
( { model
@ -179,7 +183,7 @@ update msg model =
else
Just (Icons.volume1 True)
}
, setVolume { volume = v, muted = m }
, setVolume model.id { volume = v, muted = m }
)
AnimationFrameDelta delta ->
@ -261,76 +265,76 @@ update msg model =
( { model | showMiniature = miniature }, Cmd.none )
port polymnyVideoInit : String -> Cmd msg
port polymnyVideoInit : ( String, String ) -> Cmd msg
init : String -> Cmd msg
init =
polymnyVideoInit
init : String -> String -> Cmd msg
init id url =
polymnyVideoInit ( id, url )
port polymnyVideoPlayPause : () -> Cmd msg
port polymnyVideoPlayPause : String -> Cmd msg
playPause : Cmd msg
playPause : String -> Cmd msg
playPause =
polymnyVideoPlayPause ()
polymnyVideoPlayPause
port polymnyVideoSeek : Float -> Cmd msg
port polymnyVideoSeek : ( String, Float ) -> Cmd msg
seek : Float -> Cmd msg
seek =
polymnyVideoSeek
seek : String -> Float -> Cmd msg
seek id s =
polymnyVideoSeek ( id, s )
port polymnyVideoRequestFullscreen : () -> Cmd msg
port polymnyVideoRequestFullscreen : String -> Cmd msg
requestFullscreen : Cmd msg
requestFullscreen : String -> Cmd msg
requestFullscreen =
polymnyVideoRequestFullscreen ()
polymnyVideoRequestFullscreen
port polymnyVideoExitFullscreen : () -> Cmd msg
port polymnyVideoExitFullscreen : String -> Cmd msg
exitFullscreen : Cmd msg
exitFullscreen : String -> Cmd msg
exitFullscreen =
polymnyVideoExitFullscreen ()
polymnyVideoExitFullscreen
port polymnyVideoSetPlaybackRate : Float -> Cmd msg
port polymnyVideoSetPlaybackRate : ( String, Float ) -> Cmd msg
setPlaybackRate : Float -> Cmd msg
setPlaybackRate =
polymnyVideoSetPlaybackRate
setPlaybackRate : String -> Float -> Cmd msg
setPlaybackRate id playbackRate =
polymnyVideoSetPlaybackRate ( id, playbackRate )
port polymnyVideoSetQuality : Quality -> Cmd msg
port polymnyVideoSetQuality : ( String, Quality ) -> Cmd msg
setQuality : Quality -> Cmd msg
setQuality =
polymnyVideoSetQuality
setQuality : String -> Quality -> Cmd msg
setQuality id quality =
polymnyVideoSetQuality ( id, quality )
port polymnyVideoSetSubtitleTrack : Int -> Cmd msg
port polymnyVideoSetSubtitleTrack : ( String, Int ) -> Cmd msg
setSubtitleTrack : Int -> Cmd msg
setSubtitleTrack =
polymnyVideoSetSubtitleTrack
setSubtitleTrack : String -> Int -> Cmd msg
setSubtitleTrack id track =
polymnyVideoSetSubtitleTrack ( id, track )
port polymnyVideoSetVolume : { volume : Float, muted : Bool } -> Cmd msg
port polymnyVideoSetVolume : ( String, { volume : Float, muted : Bool } ) -> Cmd msg
setVolume : { volume : Float, muted : Bool } -> Cmd msg
setVolume =
polymnyVideoSetVolume
setVolume : String -> { volume : Float, muted : Bool } -> Cmd msg
setVolume id volume =
polymnyVideoSetVolume ( id, volume )
port polymnyVideoNowHasQualities : (List Int -> msg) -> Sub msg

View File

@ -1,4 +1,4 @@
module Events exposing (player, seekBar, subs, video)
module Video.Events exposing (overlay, player, seekBar, subs, video)
import Browser.Events
import Element
@ -6,8 +6,8 @@ import Html
import Html.Attributes
import Html.Events
import Json.Decode as Decode
import Quality
import Video exposing (Video)
import Video.Quality as Quality
subs : Video -> Sub Video.Msg
@ -51,13 +51,12 @@ player =
List.map Element.htmlAttribute
[ Html.Events.on "fullscreenchange" decodeFullscreenChange
, Html.Events.on "mousemove" (Decode.succeed Video.MouseMove)
, Html.Events.on "click" (Decode.succeed Video.PlayPause)
]
video : List (Html.Attribute Video.Msg)
video =
[ Html.Attributes.id "video"
video : Video -> List (Html.Attribute Video.Msg)
video model =
[ Html.Attributes.id model.id
, Html.Events.on "playing" (Decode.succeed Video.NowPlaying)
, Html.Events.on "pause" (Decode.succeed Video.NowPaused)
, Html.Events.on "durationchange" decodeDurationChanged
@ -69,6 +68,12 @@ video =
]
overlay : List (Element.Attribute Video.Msg)
overlay =
[ Element.htmlAttribute (Html.Events.on "click" (Decode.succeed Video.PlayPause))
]
seekBar : Video -> List (Element.Attribute Video.Msg)
seekBar model =
List.map Element.htmlAttribute

View File

@ -1,4 +1,4 @@
module Icons exposing (..)
module Video.Icons exposing (..)
import Element exposing (Element)
import Html exposing (Html)
@ -10,12 +10,14 @@ svgFeatherIcon : String -> List (Svg msg) -> Bool -> Element msg
svgFeatherIcon className lines f =
Element.html
(svg
[ if f then
class <| "filled feather feather-" ++ className
[ class <| "feather feather-" ++ className
, fill
(if f then
"currentColor"
else
class <| "feather feather-" ++ className
, fill "none"
else
"none"
)
, height "24"
, stroke "currentColor"
, strokeLinecap "round"

View File

@ -1,4 +1,4 @@
module Quality exposing (Quality, decode, isSameOption, toString)
module Video.Quality exposing (Quality, decode, isSameOption, toString)
import Json.Decode as Decode

View File

@ -1,218 +1,38 @@
module Views exposing (..)
module Video.Views exposing (..)
import Element exposing (Element)
import Element.Background as Background
import Element.Border as Border
import Element.Font as Font
import Element.Input as Input
import Events as Events
import Html
import Html.Attributes
import Icons
import Quality
import Simple.Animation as Animation exposing (Animation)
import Simple.Animation.Animated as Animated
import Simple.Animation.Property as P
import Video exposing (Video)
import Video.Events as Events
import Video.Icons as Icons
import Video.Quality as Quality
view : Video -> Element Video.Msg
view model =
Element.el
(Element.inFront (overlay model)
:: Element.inFront (menu model)
:: Element.width Element.fill
:: Element.height Element.fill
:: Background.color (Element.rgb 0 0 0)
:: Element.htmlAttribute (Html.Attributes.id (model.id ++ "-full"))
:: Events.player
)
(Element.html (Html.video (Html.Attributes.class "wf" :: Events.video model) []))
embed : ( Int, Int ) -> Video -> Element Video.Msg
embed screenSize model =
let
seen =
round (model.position * 1000)
loaded =
List.filter (\( start, end ) -> start < model.position) model.loaded
loadedToShow =
every model.duration loaded
showRange : ( Float, Float, Bool ) -> Element msg
showRange ( start, end, isLoaded ) =
let
portion =
round (1000 * (end - start))
in
Element.el
[ Element.width (Element.fillPortion portion)
, Element.height Element.fill
, if isLoaded then
Background.color (Element.rgba 1 1 1 0.5)
else
Background.color (Element.rgba 1 1 1 0)
]
Element.none
loadedElement =
Element.row
[ Element.width Element.fill
, Element.height (Element.px 5)
, Element.centerY
, Border.rounded 5
]
(List.map showRange loadedToShow)
remaining =
round ((model.duration - model.position) * 1000)
overlay =
Element.el
[ Element.width Element.fill
, Element.height Element.fill
, Font.color (Element.rgb 1 1 1)
, if not model.playing then
Background.color (Element.rgba 0 0 0 0.5)
else
Background.color (Element.rgba 0 0 0 0)
]
(case ( model.playing, model.showIcon ) of
( False, _ ) ->
Element.el
[ Element.centerX
, Element.centerY
, Element.scale 10
]
(Icons.play True)
( _, Just icon ) ->
animatedEl fadeOutZoom
[ Background.color (Element.rgb 0 0 0)
, Border.rounded 100
, Element.padding 10
, Element.centerX
, Element.centerY
]
icon
_ ->
Element.none
)
bar =
animatedEl
(if model.animationFrame < 3000 then
fadeIn
else
fadeOut
)
[ Element.width Element.fill, Element.alignBottom ]
(Element.column
[ Element.width Element.fill
, Element.alignBottom
, Font.color (Element.rgba 1 1 1 0.85)
]
[ settings model
, Element.column
[ Element.width Element.fill
, Element.padding 10
, Background.gradient { angle = 0, steps = [ Element.rgba 0 0 0 0.75, Element.rgba 0 0 0 0 ] }
]
[ Element.row
[ Element.width Element.fill
, Element.height (Element.px 30)
, Border.rounded 5
, Element.behindContent
(Element.el
[ Background.color (Element.rgba 1 1 1 0.25)
, Element.width Element.fill
, Element.height (Element.px 5)
, Element.centerY
, Border.rounded 5
]
Element.none
)
, Element.behindContent loadedElement
, Element.inFront
(Element.el
(Element.width Element.fill
:: Element.height Element.fill
:: Element.pointer
:: Events.seekBar model
)
Element.none
)
, Element.above
(case model.showMiniature of
Just ( position, size ) ->
let
relativePosition =
toFloat position / toFloat size
percentage =
String.fromFloat (relativePosition * 100) ++ "%"
miniatureId =
round (relativePosition * 100)
miniatureIdString =
"miniature-" ++ String.padLeft 3 '0' (String.fromInt miniatureId) ++ ".png"
miniatureUrl =
model.url
|> String.split "/"
|> List.reverse
|> List.drop 1
|> (\list -> miniatureIdString :: list)
|> List.reverse
|> String.join "/"
rightPosition =
(position - 180 - 6)
|> max 0
|> min (size - 360 - 28)
|> toFloat
in
Element.column
[ Element.moveRight rightPosition
, Element.spacing 10
]
[ Element.image
[ Border.color (Element.rgb 1 1 1)
, Border.width 2
]
{ src = miniatureUrl, description = "miniature" }
, Element.el
[ Element.centerX
, Font.shadow
{ offset = ( 0, 0 )
, blur = 4
, color = Element.rgb 0 0 0
}
]
(Element.text (formatTime (relativePosition * model.duration)))
]
_ ->
Element.none
)
]
[ Element.el
[ Background.color (Element.rgba 1 0 0 0.75)
, Element.width (Element.fillPortion seen)
, Element.height Element.fill
, Border.roundEach { topLeft = 5, topRight = 0, bottomLeft = 5, bottomRight = 0 }
, Element.height (Element.px 5)
, Element.centerY
]
Element.none
, Element.el [ Element.width (Element.fillPortion remaining) ] Element.none
]
, Element.row
[ Element.spacing 10, Element.width Element.fill ]
[ playPauseButton model.playing
, volumeButton model.volume model.muted
, Element.el [ Element.moveDown 2.5 ] (Element.text (formatTime model.position ++ " / " ++ formatTime model.duration))
, Element.row [ Element.spacing 10, Element.alignRight ]
[ settingsButton, fullscreenButton model.isFullscreen ]
]
]
]
)
videoAspectRatio =
toFloat (Tuple.first model.size) / toFloat (Tuple.second model.size)
@ -239,12 +59,12 @@ embed screenSize model =
)
in
Element.el
(Element.inFront overlay
:: Element.inFront bar
(Element.inFront (overlay model)
:: Element.inFront (menu model)
:: Element.width Element.fill
:: Element.height Element.fill
:: Background.color (Element.rgb 0 0 0)
:: Element.htmlAttribute (Html.Attributes.id "full")
:: Element.htmlAttribute (Html.Attributes.id (model.id ++ "-full"))
:: Events.player
)
(Element.html
@ -254,7 +74,7 @@ embed screenSize model =
:: Html.Attributes.height h
:: Html.Attributes.style "top" (String.fromInt y ++ "px")
:: Html.Attributes.style "left" (String.fromInt x ++ "px")
:: Events.video
:: Events.video model
)
[]
)
@ -432,6 +252,215 @@ settings model =
)
overlay : Video -> Element Video.Msg
overlay model =
Element.el
(Element.width Element.fill
:: Element.height Element.fill
:: Font.color (Element.rgb 1 1 1)
:: (if not model.playing then
Background.color (Element.rgba 0 0 0 0.5)
else
Background.color (Element.rgba 0 0 0 0)
)
:: Events.overlay
)
(case ( model.playing, model.showIcon ) of
( False, _ ) ->
Element.el
[ Element.centerX
, Element.centerY
, Element.scale 10
]
(Icons.play True)
( _, Just icon ) ->
animatedEl fadeOutZoom
[ Background.color (Element.rgb 0 0 0)
, Border.rounded 100
, Element.padding 10
, Element.centerX
, Element.centerY
]
icon
_ ->
Element.none
)
menu : Video -> Element Video.Msg
menu model =
animatedEl
(if model.animationFrame < 3000 then
fadeIn
else
fadeOut
)
[ Element.width Element.fill, Element.alignBottom ]
(Element.column
[ Element.width Element.fill
, Element.alignBottom
, Font.color (Element.rgba 1 1 1 0.85)
]
[ settings model
, Element.column
[ Element.width Element.fill
, Element.padding 10
, Background.gradient { angle = 0, steps = [ Element.rgba 0 0 0 0.75, Element.rgba 0 0 0 0 ] }
]
[ seekbar model
, Element.row
[ Element.spacing 10, Element.width Element.fill ]
[ playPauseButton model.playing
, volumeButton model.volume model.muted
, Element.el [ Element.moveDown 2.5 ] (Element.text (formatTime model.position ++ " / " ++ formatTime model.duration))
, Element.row [ Element.spacing 10, Element.alignRight ]
[ settingsButton, fullscreenButton model.isFullscreen ]
]
]
]
)
seekbar : Video -> Element Video.Msg
seekbar model =
let
seen =
round (model.position * 1000)
loaded =
List.filter (\( start, end ) -> start < model.position) model.loaded
loadedToShow =
every model.duration loaded
showRange : ( Float, Float, Bool ) -> Element msg
showRange ( start, end, isLoaded ) =
let
portion =
round (1000 * (end - start))
in
Element.el
[ Element.width (Element.fillPortion portion)
, Element.height Element.fill
, if isLoaded then
Background.color (Element.rgba 1 1 1 0.5)
else
Background.color (Element.rgba 1 1 1 0)
]
Element.none
loadedElement =
Element.row
[ Element.width Element.fill
, Element.height (Element.px 5)
, Element.centerY
, Border.rounded 5
]
(List.map showRange loadedToShow)
remaining =
round ((model.duration - model.position) * 1000)
in
Element.row
[ Element.width Element.fill
, Element.height (Element.px 30)
, Border.rounded 5
, Element.behindContent
(Element.el
[ Background.color (Element.rgba 1 1 1 0.25)
, Element.width Element.fill
, Element.height (Element.px 5)
, Element.centerY
, Border.rounded 5
]
Element.none
)
, Element.behindContent loadedElement
, Element.inFront
(Element.el
(Element.width Element.fill
:: Element.height Element.fill
:: Element.pointer
:: Events.seekBar model
)
Element.none
)
, Element.above (miniature model)
]
[ Element.el
[ Background.color (Element.rgba 1 0 0 0.75)
, Element.width (Element.fillPortion seen)
, Element.height Element.fill
, Border.roundEach { topLeft = 5, topRight = 0, bottomLeft = 5, bottomRight = 0 }
, Element.height (Element.px 5)
, Element.centerY
]
Element.none
, Element.el [ Element.width (Element.fillPortion remaining) ] Element.none
]
miniature : Video -> Element Video.Msg
miniature model =
case model.showMiniature of
Just ( position, size ) ->
let
relativePosition =
toFloat position / toFloat size
percentage =
String.fromFloat (relativePosition * 100) ++ "%"
miniatureId =
round (relativePosition * 100)
miniatureIdString =
"miniature-" ++ String.padLeft 3 '0' (String.fromInt miniatureId) ++ ".png"
miniatureUrl =
model.url
|> String.split "/"
|> List.reverse
|> List.drop 1
|> (\list -> miniatureIdString :: list)
|> List.reverse
|> String.join "/"
rightPosition =
(position - 180 - 6)
|> max 0
|> min (size - 360 - 28)
|> toFloat
in
Element.column
[ Element.moveRight rightPosition
, Element.spacing 10
]
[ Element.image
[ Border.color (Element.rgb 1 1 1)
, Border.width 2
]
{ src = miniatureUrl, description = "miniature" }
, Element.el
[ Element.centerX
, Font.shadow
{ offset = ( 0, 0 )
, blur = 4
, color = Element.rgb 0 0 0
}
]
(Element.text (formatTime (relativePosition * model.duration)))
]
_ ->
Element.none
playPauseButton : Bool -> Element Video.Msg
playPauseButton playing =
let