feat: audio player (#110)
* implement new audio player * refactor audoi player * fix lint errors * fix ts errors * remove unnecc yarn files * refactor by review comments * Article card feed mode * refactor by review comments --------- Co-authored-by: kvakazyambra <kvakazyambra@gmail.com>
This commit is contained in:
parent
09bf522ab7
commit
a18b6b9e6d
6
public/icons/lyrics-media.svg
Normal file
6
public/icons/lyrics-media.svg
Normal file
|
@ -0,0 +1,6 @@
|
|||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M4.5 3V21H19.5V8.4L13.875 3H4.5ZM17.625 19.2H6.375V4.8H12V10.2H17.625V19.2ZM13.8751 8.4V5.54575L16.8483 8.4H13.8751Z" fill="#141414"/>
|
||||
<path d="M8.25 8.3999H10.125V10.1999H8.25V8.3999Z" fill="#141414"/>
|
||||
<path d="M8.25 12H15.75V13.8H8.25V12Z" fill="#141414"/>
|
||||
<path d="M8.25 15.6001H15.75V17.4001H8.25V15.6001Z" fill="#141414"/>
|
||||
</svg>
|
After Width: | Height: | Size: 440 B |
4
public/icons/pause.svg
Normal file
4
public/icons/pause.svg
Normal file
|
@ -0,0 +1,4 @@
|
|||
<svg width="14" height="17" viewBox="0 0 14 17" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M0.5 0.500244H5.5V16.5002H0.5V0.500244Z" fill="#141414"/>
|
||||
<path d="M8.5 0.500245H13.5V16.5002H8.5V0.500245Z" fill="#141414"/>
|
||||
</svg>
|
After Width: | Height: | Size: 238 B |
3
public/icons/play.svg
Normal file
3
public/icons/play.svg
Normal file
|
@ -0,0 +1,3 @@
|
|||
<svg width="10" height="14" viewBox="0 0 10 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M1.66948e-07 0.000244141L10 7.00024L0 14.0002L1.66948e-07 0.000244141Z" fill="#CCCCCC"/>
|
||||
</svg>
|
After Width: | Height: | Size: 201 B |
4
public/icons/player-arrow.svg
Normal file
4
public/icons/player-arrow.svg
Normal file
|
@ -0,0 +1,4 @@
|
|||
<svg width="15" height="13" viewBox="0 0 15 13" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M14.9992 0.998535L5.99924 6.99853L14.9992 12.9985V0.998535Z" fill="#141414"/>
|
||||
<path d="M9 0.999312L0 6.99931L9 12.9993V0.999312Z" fill="#141414"/>
|
||||
</svg>
|
After Width: | Height: | Size: 259 B |
3
public/icons/share-media.svg
Normal file
3
public/icons/share-media.svg
Normal file
|
@ -0,0 +1,3 @@
|
|||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M21.7819 8.84476L16.1937 3.21986C15.9808 3.00516 15.6602 2.94092 15.3818 3.05727C15.1033 3.17341 14.9218 3.44691 14.9218 3.75015V5.98109C13.3751 6.00453 9.89278 6.36927 7.07641 8.93941C4.48996 11.2998 3.12121 14.8884 3.00769 19.6101C3.00374 19.8204 3 20.0314 3 20.25C3 20.6198 3.26777 20.9345 3.63094 20.9912C3.66919 20.9971 3.70765 21 3.7457 21C4.06544 21 4.35567 20.7924 4.45524 20.4775C4.47915 20.4024 6.85271 13.1908 14.9218 12.7698L14.9216 15.0003C14.9216 15.3035 15.103 15.577 15.3816 15.6932C15.659 15.8093 15.9804 15.7453 16.1935 15.5306L21.7817 9.90566C22.0728 9.61269 22.0728 9.1381 21.7817 8.84512L21.7819 8.84476ZM16.4119 13.1894V12C16.4119 11.5859 16.0782 11.25 15.6668 11.25C9.88908 11.25 6.53188 14.3027 4.75056 16.8064C5.2229 13.9045 6.33704 11.6391 8.07775 10.0506C10.6195 7.73114 13.7927 7.47797 15.0556 7.47797C15.2966 7.47797 15.4683 7.48717 15.5527 7.49303C15.6254 7.5014 15.6666 7.49994 15.6668 7.49994C16.0672 7.48487 16.4119 7.15319 16.4119 6.74995V5.56053L20.2012 9.3749L16.4119 13.1894Z" fill="black"/>
|
||||
</svg>
|
After Width: | Height: | Size: 1.1 KiB |
5
public/icons/volume.svg
Normal file
5
public/icons/volume.svg
Normal file
|
@ -0,0 +1,5 @@
|
|||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M11 -0.000732356L11 17.9993H9.5L3.5 13.4993H0L3.934e-07 4.49927H3.5L9.5 -0.000732422L11 -0.000732356Z" fill="#141414"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M13 8.99927C13 8.44698 12.5523 7.99927 12 7.99927V5.99927C13.6569 5.99927 15 7.34241 15 8.99927C15 10.6561 13.6569 11.9993 12 11.9993V9.99927C12.5523 9.99927 13 9.55155 13 8.99927Z" fill="#141414"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M16 8.99927C16 6.79013 14.2091 4.99927 12 4.99927V2.99927C15.3137 2.99927 18 5.68556 18 8.99927C18 12.313 15.3137 14.9993 12 14.9993V12.9993C14.2091 12.9993 16 11.2084 16 8.99927Z" fill="#141414"/>
|
||||
</svg>
|
After Width: | Height: | Size: 727 B |
|
@ -13,7 +13,7 @@ img {
|
|||
max-width: 100%;
|
||||
}
|
||||
|
||||
.shoutHeader {
|
||||
.shoutTopic {
|
||||
margin-bottom: 2em;
|
||||
|
||||
@include media-breakpoint-up(md) {
|
||||
|
@ -21,15 +21,34 @@ img {
|
|||
}
|
||||
}
|
||||
|
||||
.shoutHeader {
|
||||
display: flex;
|
||||
flex-flow: row wrap;
|
||||
}
|
||||
|
||||
.shoutCover {
|
||||
background-size: cover;
|
||||
height: 0;
|
||||
padding-bottom: 56.2%;
|
||||
width: 40%;
|
||||
margin-left: auto;
|
||||
|
||||
& img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
@include media-breakpoint-down(sm) {
|
||||
width: 100%;
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.shoutBody {
|
||||
font-size: 1.7rem;
|
||||
line-height: 1.6;
|
||||
margin-top: 32px;
|
||||
|
||||
font-weight: 400;
|
||||
font-size: 17px;
|
||||
line-height: 28px;
|
||||
letter-spacing: -0.01em;
|
||||
color: #404040;
|
||||
|
||||
img {
|
||||
display: block;
|
||||
|
|
|
@ -1,66 +0,0 @@
|
|||
import { createEffect, createMemo, createSignal, onMount, For } from 'solid-js'
|
||||
|
||||
import type { Shout } from '../../graphql/types.gen'
|
||||
import { Soundwave } from './Soundwave'
|
||||
|
||||
type MediaItem = any
|
||||
|
||||
export default (props: { shout: Shout }) => {
|
||||
const media = createMemo<any[]>(() => {
|
||||
if (props.shout.media) {
|
||||
console.debug(props.shout.media)
|
||||
return [...JSON.parse(props.shout.media)]
|
||||
}
|
||||
return []
|
||||
})
|
||||
let audioRef: HTMLAudioElement
|
||||
const [currentTrack] = createSignal(media()[0])
|
||||
const [paused, setPaused] = createSignal(true)
|
||||
const togglePlayPause = () => setPaused(!paused())
|
||||
const playMedia = (m: MediaItem) => {
|
||||
audioRef.src = m.get('src')
|
||||
audioRef.play()
|
||||
}
|
||||
const [audioContext, setAudioContext] = createSignal<AudioContext>()
|
||||
onMount(() => setAudioContext(new AudioContext()))
|
||||
createEffect(() => (paused() ? audioRef.play : audioRef.pause)())
|
||||
return (
|
||||
<div class="audio-container">
|
||||
<div class="audio-img">
|
||||
<img
|
||||
class="ligthbox-img lazyload zoom-in"
|
||||
width="320"
|
||||
height="320"
|
||||
alt={props.shout.title}
|
||||
title={props.shout.title}
|
||||
src={props.shout.cover}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="audio-player-list">
|
||||
<div class="player current-track">
|
||||
<div class="player-title">{currentTrack().title}</div>
|
||||
<i class="fas fa-pause fa-3x fa-fw" onClick={togglePlayPause} />
|
||||
<div class="player-progress">
|
||||
<Soundwave context={audioContext()} url={currentTrack().src} />
|
||||
<span class="track-position">{`${audioRef.currentTime} / ${audioRef.duration}`}</span>
|
||||
</div>
|
||||
<audio ref={audioRef} />
|
||||
</div>
|
||||
|
||||
<ul class="all-tracks">
|
||||
<For each={media()}>
|
||||
{(m: MediaItem) => (
|
||||
<li>
|
||||
<div class="player-status">
|
||||
<i class="fas fa-play fa-fw" onClick={() => playMedia(m)} />
|
||||
</div>
|
||||
<span class="track-title">{m.title}</span>
|
||||
</li>
|
||||
)}
|
||||
</For>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
298
src/components/Article/AudioPlayer/AudioPlayer.module.scss
Normal file
298
src/components/Article/AudioPlayer/AudioPlayer.module.scss
Normal file
|
@ -0,0 +1,298 @@
|
|||
.allTracks {
|
||||
color: red;
|
||||
}
|
||||
|
||||
.playerHeader {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
@include media-breakpoint-down(sm) {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.playerTitle {
|
||||
max-width: 50%;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
@include media-breakpoint-down(sm) {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.playerControls {
|
||||
display: flex;
|
||||
min-width: 160px;
|
||||
align-items: center;
|
||||
margin-left: auto;
|
||||
|
||||
& > button {
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
@include media-breakpoint-down(sm) {
|
||||
margin-top: 20px;
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.playButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background-color: #141414;
|
||||
|
||||
& img {
|
||||
width: 14px;
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.playButtonInvertPause img {
|
||||
filter: invert(100%);
|
||||
}
|
||||
|
||||
.playButtonInvertPlay img {
|
||||
filter: brightness(130%);
|
||||
}
|
||||
|
||||
.controlsButton {
|
||||
margin-left: 19px;
|
||||
|
||||
& img {
|
||||
width: 15px;
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.controlsButtonNext {
|
||||
margin-left: 26px;
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.volumeButton {
|
||||
margin-left: 23px;
|
||||
|
||||
& img {
|
||||
width: 18px;
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.timeline {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 28px;
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
.progress {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid #cccccc;
|
||||
}
|
||||
|
||||
.progressFilled {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
box-sizing: border-box;
|
||||
border-bottom: 4px solid #141414;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
display: block;
|
||||
position: absolute;
|
||||
bottom: -8px;
|
||||
right: -8px;
|
||||
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
border: 4px solid #141414;
|
||||
background-color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.progressTiming {
|
||||
width: 100%;
|
||||
padding-top: 14px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
font-weight: 500;
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
letter-spacing: 0.003em;
|
||||
}
|
||||
|
||||
.volumeContainer {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.volume {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
right: 0;
|
||||
bottom: 14px;
|
||||
|
||||
height: 28px;
|
||||
-webkit-appearance: none;
|
||||
margin: 10px 0;
|
||||
width: 80px;
|
||||
background: transparent;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&::-moz-range-thumb,
|
||||
&::-webkit-slider-thumb {
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
border-radius: 100px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
|
||||
margin-top: -4px;
|
||||
}
|
||||
&:focus::-webkit-slider-runnable-track,
|
||||
&::-moz-range-thumb,
|
||||
&::-webkit-slider-thumb,
|
||||
&::-webkit-slider-runnable-track,
|
||||
&::-moz-range-track,
|
||||
&::-ms-track,
|
||||
&::-ms-fill-lower,
|
||||
&::-ms-fill-upper,
|
||||
&::-ms-thumb {
|
||||
background: #0e0e0e;
|
||||
}
|
||||
|
||||
&::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
&::-webkit-slider-runnable-track,
|
||||
&::-moz-range-track,
|
||||
&::-ms-track {
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
cursor: pointer;
|
||||
animate: 0.2s;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
&::-ms-fill-lower,
|
||||
&::-ms-fill-upper {
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
&::-ms-thumb {
|
||||
margin-top: 1px;
|
||||
height: 15px;
|
||||
width: 15px;
|
||||
border-radius: 5px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&:focus::-ms-fill-lower,
|
||||
&:focus::-ms-fill-upper {
|
||||
background: #38bdf8;
|
||||
}
|
||||
}
|
||||
|
||||
.playlist {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
list-style-type: none;
|
||||
margin: 32px 0 58px;
|
||||
padding: 0;
|
||||
|
||||
& > li {
|
||||
border-top: 1px solid #e9e9ee;
|
||||
}
|
||||
}
|
||||
|
||||
.playlistItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
min-height: 56px;
|
||||
padding: 16px 0;
|
||||
}
|
||||
|
||||
.playlistItemPlayButton {
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
width: 14px;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.playlistItemTitle {
|
||||
max-width: 254px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
margin-left: 17px;
|
||||
font-weight: 400;
|
||||
font-size: 16px;
|
||||
line-height: 22px;
|
||||
letter-spacing: -0.01em;
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
.playlistItemControls {
|
||||
display: flex;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.playlistItemDuration {
|
||||
margin-left: 22px;
|
||||
font-weight: 400;
|
||||
font-size: 16px;
|
||||
line-height: 22px;
|
||||
letter-spacing: -0.01em;
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
.timelinePlaceholder {
|
||||
width: 100%;
|
||||
height: 67px;
|
||||
}
|
||||
|
||||
.playerHeaderPlaceholder {
|
||||
width: 100%;
|
||||
height: 67px;
|
||||
}
|
||||
|
||||
.playlistPlaceholder {
|
||||
width: 100%;
|
||||
height: 67px;
|
||||
}
|
||||
|
||||
.shareMedia {
|
||||
margin-left: auto;
|
||||
}
|
218
src/components/Article/AudioPlayer/AudioPlayer.tsx
Normal file
218
src/components/Article/AudioPlayer/AudioPlayer.tsx
Normal file
|
@ -0,0 +1,218 @@
|
|||
import { createEffect, createSignal, onMount, Show } from 'solid-js'
|
||||
|
||||
import { PlayerHeader } from './PlayerHeader'
|
||||
import { PlayerPlaylist } from './PlayerPlaylist'
|
||||
|
||||
import styles from './AudioPlayer.module.scss'
|
||||
|
||||
export type MediaItem = {
|
||||
id?: number
|
||||
body: string
|
||||
pic: string
|
||||
title: string
|
||||
url: string
|
||||
isCurrent: boolean
|
||||
isPlaying: boolean
|
||||
}
|
||||
|
||||
const prepareMedia = (media: MediaItem[]) =>
|
||||
media.map((item, index) => ({
|
||||
...item,
|
||||
id: index,
|
||||
isCurrent: false,
|
||||
isPlaying: false
|
||||
}))
|
||||
|
||||
const progressUpdate = (audioRef, progressFilledRef, duration) => {
|
||||
progressFilledRef.style.width = `${(audioRef.currentTime / duration) * 100 || 0}%`
|
||||
}
|
||||
|
||||
const scrub = (event, progressRef, duration, audioRef) => {
|
||||
audioRef.currentTime = (event.offsetX / progressRef.offsetWidth) * duration
|
||||
}
|
||||
|
||||
const getFormattedTime = (point) => new Date(point * 1000).toISOString().slice(14, -5)
|
||||
|
||||
export default (props: { media: MediaItem[]; articleSlug: string; body: string }) => {
|
||||
let audioRef: HTMLAudioElement
|
||||
let progressRef: HTMLDivElement
|
||||
let progressFilledRef: HTMLDivElement
|
||||
|
||||
const [audioContext, setAudioContext] = createSignal<AudioContext>()
|
||||
const [gainNode, setGainNode] = createSignal<GainNode>()
|
||||
|
||||
const [tracks, setTracks] = createSignal<MediaItem[] | null>(prepareMedia(props.media))
|
||||
|
||||
const [duration, setDuration] = createSignal<number>(0)
|
||||
const [currentTimeContent, setCurrentTimeContent] = createSignal<string>('00:00')
|
||||
const [currentDurationContent, setCurrentDurationContent] = createSignal<string>('00:00')
|
||||
|
||||
const [mousedown, setMousedown] = createSignal<boolean>(false)
|
||||
|
||||
const getCurrentTrack = () =>
|
||||
tracks().find((track) => track.isCurrent) ||
|
||||
(() => {
|
||||
setTracks(
|
||||
tracks().map((track, index) => ({
|
||||
...track,
|
||||
isCurrent: index === 0
|
||||
}))
|
||||
)
|
||||
|
||||
return tracks()[0]
|
||||
})()
|
||||
|
||||
createEffect(() => {
|
||||
if (audioRef.src !== getCurrentTrack().url) {
|
||||
audioRef.src = getCurrentTrack().url
|
||||
|
||||
audioRef.load()
|
||||
}
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (getCurrentTrack() && duration()) {
|
||||
setCurrentDurationContent(getFormattedTime(duration()))
|
||||
}
|
||||
})
|
||||
|
||||
const playMedia = async (m: MediaItem) => {
|
||||
setTracks(
|
||||
tracks().map((track) => ({
|
||||
...track,
|
||||
isCurrent: track.id === m.id ? true : false,
|
||||
isPlaying: track.id === m.id ? !track.isPlaying : false
|
||||
}))
|
||||
)
|
||||
|
||||
progressUpdate(audioRef, progressFilledRef, duration())
|
||||
|
||||
if (audioContext().state === 'suspended') audioContext().resume()
|
||||
|
||||
if (getCurrentTrack().isPlaying) {
|
||||
await audioRef.play()
|
||||
} else {
|
||||
audioRef.pause()
|
||||
}
|
||||
}
|
||||
|
||||
const setTimes = () => {
|
||||
setCurrentTimeContent(getFormattedTime(audioRef.currentTime))
|
||||
}
|
||||
|
||||
const handleAudioEnd = () => {
|
||||
progressFilledRef.style.width = '0%'
|
||||
audioRef.currentTime = 0
|
||||
}
|
||||
|
||||
const handleAudioTimeUpdate = () => {
|
||||
progressUpdate(audioRef, progressFilledRef, duration())
|
||||
|
||||
setTimes()
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
setAudioContext(new AudioContext())
|
||||
setGainNode(audioContext().createGain())
|
||||
|
||||
setTimes()
|
||||
|
||||
const track = audioContext().createMediaElementSource(audioRef)
|
||||
track.connect(gainNode()).connect(audioContext().destination)
|
||||
})
|
||||
|
||||
const playPrevTrack = () => {
|
||||
const { id } = getCurrentTrack()
|
||||
const currIndex = tracks().findIndex((track) => track.id === id)
|
||||
|
||||
const getUpdatedStatus = (trackId) =>
|
||||
currIndex === 0
|
||||
? trackId === tracks()[tracks().length - 1].id
|
||||
: trackId === tracks()[currIndex - 1].id
|
||||
|
||||
setTracks(
|
||||
tracks().map((track) => ({
|
||||
...track,
|
||||
isCurrent: getUpdatedStatus(track.id),
|
||||
isPlaying: getUpdatedStatus(track.id)
|
||||
}))
|
||||
)
|
||||
}
|
||||
|
||||
const playNextTrack = () => {
|
||||
const { id } = getCurrentTrack()
|
||||
const currIndex = tracks().findIndex((track) => track.id === id)
|
||||
|
||||
const getUpdatedStatus = (trackId) =>
|
||||
currIndex === tracks().length - 1
|
||||
? trackId === tracks()[0].id
|
||||
: trackId === tracks()[currIndex + 1].id
|
||||
|
||||
setTracks(
|
||||
tracks().map((track) => ({
|
||||
...track,
|
||||
isCurrent: getUpdatedStatus(track.id),
|
||||
isPlaying: getUpdatedStatus(track.id)
|
||||
}))
|
||||
)
|
||||
}
|
||||
|
||||
const handleOnAudioMetadataLoad = ({ target }) => {
|
||||
setDuration(target.duration)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Show when={getCurrentTrack()}>
|
||||
<PlayerHeader
|
||||
onPlayMedia={() => playMedia(getCurrentTrack())}
|
||||
getCurrentTrack={getCurrentTrack}
|
||||
playPrevTrack={playPrevTrack}
|
||||
playNextTrack={playNextTrack}
|
||||
gainNode={gainNode()}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
<Show when={getCurrentTrack()}>
|
||||
<div class={styles.timeline}>
|
||||
<div
|
||||
class={styles.progress}
|
||||
ref={progressRef}
|
||||
onClick={(e) => scrub(e, progressRef, duration(), audioRef)}
|
||||
onMouseMove={(e) => mousedown() && scrub(e, progressRef, duration(), audioRef)}
|
||||
onMouseDown={() => setMousedown(true)}
|
||||
onMouseUp={() => setMousedown(false)}
|
||||
>
|
||||
<div class={styles.progressFilled} ref={progressFilledRef}></div>
|
||||
</div>
|
||||
<div class={styles.progressTiming}>
|
||||
<span>{currentTimeContent()}</span>
|
||||
<span>{currentDurationContent()}</span>
|
||||
</div>
|
||||
<audio
|
||||
ref={audioRef}
|
||||
onTimeUpdate={handleAudioTimeUpdate}
|
||||
onCanPlay={() => {
|
||||
if (getCurrentTrack().isPlaying) {
|
||||
audioRef.play()
|
||||
}
|
||||
}}
|
||||
onLoadedMetadata={handleOnAudioMetadataLoad}
|
||||
onEnded={handleAudioEnd}
|
||||
crossorigin="anonymous"
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={tracks()}>
|
||||
<PlayerPlaylist
|
||||
playMedia={playMedia}
|
||||
tracks={tracks()}
|
||||
getCurrentTrack={getCurrentTrack}
|
||||
articleSlug={props.articleSlug}
|
||||
body={props.body}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
86
src/components/Article/AudioPlayer/PlayerHeader.tsx
Normal file
86
src/components/Article/AudioPlayer/PlayerHeader.tsx
Normal file
|
@ -0,0 +1,86 @@
|
|||
import { createSignal, Show } from 'solid-js'
|
||||
import { clsx } from 'clsx'
|
||||
|
||||
import { useOutsideClickHandler } from '../../../utils/useOutsideClickHandler'
|
||||
|
||||
import { Icon } from '../../_shared/Icon'
|
||||
import styles from './AudioPlayer.module.scss'
|
||||
|
||||
export const PlayerHeader = (props) => {
|
||||
let volumeRef: HTMLInputElement
|
||||
const volumeContainerRef: { current: HTMLDivElement } = {
|
||||
current: null
|
||||
}
|
||||
|
||||
const { getCurrentTrack, onPlayMedia, gainNode, playPrevTrack, playNextTrack } = props
|
||||
|
||||
const [isVolumeBarOpened, setIsVolumeBarOpened] = createSignal(false)
|
||||
|
||||
const handleVolumeChange = () => {
|
||||
gainNode.gain.value = Number(volumeRef.value)
|
||||
}
|
||||
|
||||
const toggleVolumeBar = () => {
|
||||
setIsVolumeBarOpened(!isVolumeBarOpened())
|
||||
}
|
||||
|
||||
useOutsideClickHandler({
|
||||
containerRef: volumeContainerRef,
|
||||
predicate: () => isVolumeBarOpened(),
|
||||
handler: () => toggleVolumeBar()
|
||||
})
|
||||
|
||||
return (
|
||||
<div class={styles.playerHeader}>
|
||||
<div class={styles.playerTitle}>{getCurrentTrack().title}</div>
|
||||
<div class={styles.playerControls}>
|
||||
<button
|
||||
onClick={onPlayMedia}
|
||||
class={clsx(
|
||||
styles.playButton,
|
||||
getCurrentTrack().isPlaying ? styles.playButtonInvertPause : styles.playButtonInvertPlay
|
||||
)}
|
||||
role="button"
|
||||
aria-label="Play"
|
||||
data-playing="false"
|
||||
>
|
||||
<Icon name={getCurrentTrack().isPlaying ? 'pause' : 'play'} />
|
||||
</button>
|
||||
<button
|
||||
onClick={playPrevTrack}
|
||||
class={clsx(styles.controlsButton)}
|
||||
role="button"
|
||||
aria-label="Previous"
|
||||
>
|
||||
<Icon name="player-arrow" />
|
||||
</button>
|
||||
<button
|
||||
onClick={playNextTrack}
|
||||
class={clsx(styles.controlsButton, styles.controlsButtonNext)}
|
||||
role="button"
|
||||
aria-label="Next"
|
||||
>
|
||||
<Icon name="player-arrow" />
|
||||
</button>
|
||||
<div ref={(el) => (volumeContainerRef.current = el)} class={styles.volumeContainer}>
|
||||
<Show when={isVolumeBarOpened()}>
|
||||
<input
|
||||
ref={volumeRef}
|
||||
type="range"
|
||||
id="volume"
|
||||
min="0"
|
||||
max="1"
|
||||
value="1"
|
||||
step="0.01"
|
||||
class={styles.volume}
|
||||
onChange={handleVolumeChange}
|
||||
/>
|
||||
</Show>
|
||||
<button onClick={toggleVolumeBar} class={styles.volumeButton} role="button" aria-label="Volume">
|
||||
<Icon name="volume" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
64
src/components/Article/AudioPlayer/PlayerPlaylist.tsx
Normal file
64
src/components/Article/AudioPlayer/PlayerPlaylist.tsx
Normal file
|
@ -0,0 +1,64 @@
|
|||
import { For } from 'solid-js'
|
||||
|
||||
import { SharePopup, getShareUrl } from '../SharePopup'
|
||||
import { getDescription } from '../../../utils/meta'
|
||||
|
||||
import { useLocalize } from '../../../context/localize'
|
||||
|
||||
import type { MediaItem } from './AudioPlayer'
|
||||
|
||||
import { Popover } from '../../_shared/Popover'
|
||||
import { Icon } from '../../_shared/Icon'
|
||||
|
||||
import styles from './AudioPlayer.module.scss'
|
||||
|
||||
export const PlayerPlaylist = (props) => {
|
||||
const { t } = useLocalize()
|
||||
|
||||
const { tracks, getCurrentTrack, playMedia, articleSlug, body } = props
|
||||
|
||||
return (
|
||||
<ul class={styles.playlist}>
|
||||
<For each={tracks}>
|
||||
{(m: MediaItem) => (
|
||||
<li class={styles.playlistItem}>
|
||||
<button
|
||||
class={styles.playlistItemPlayButton}
|
||||
onClick={() => playMedia(m)}
|
||||
role="button"
|
||||
aria-label="Play"
|
||||
>
|
||||
<Icon
|
||||
name={
|
||||
getCurrentTrack() && getCurrentTrack().id === m.id && getCurrentTrack().isPlaying
|
||||
? 'pause'
|
||||
: 'play'
|
||||
}
|
||||
/>
|
||||
</button>
|
||||
<div class={styles.playlistItemTitle}>{m.title}</div>
|
||||
<div class={styles.shareMedia}>
|
||||
<Popover content={t('Share')}>
|
||||
{(triggerRef: (el) => void) => (
|
||||
<div ref={triggerRef}>
|
||||
<SharePopup
|
||||
title={m.title}
|
||||
description={getDescription(body)}
|
||||
imageUrl={m.pic}
|
||||
shareUrl={getShareUrl({ pathname: `/${articleSlug}` })}
|
||||
trigger={
|
||||
<div>
|
||||
<Icon name="share-media" />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Popover>
|
||||
</div>
|
||||
</li>
|
||||
)}
|
||||
</For>
|
||||
</ul>
|
||||
)
|
||||
}
|
|
@ -15,6 +15,7 @@ type Props = {
|
|||
export const CommentDate = (props: Props) => {
|
||||
const { t } = useLocalize()
|
||||
|
||||
|
||||
const formattedDate = (date) => {
|
||||
const formatDateOptions: Intl.DateTimeFormatOptions = props.isShort
|
||||
? { month: 'long', day: 'numeric', year: 'numeric' }
|
||||
|
|
|
@ -1,7 +1,9 @@
|
|||
import { createEffect, createMemo, createSignal, For, Match, onMount, Show, Switch } from 'solid-js'
|
||||
|
||||
import { capitalize, formatDate } from '../../utils'
|
||||
import { Icon } from '../_shared/Icon'
|
||||
import { AuthorCard } from '../Author/AuthorCard'
|
||||
import { createEffect, createMemo, createSignal, For, Match, onMount, Show, Switch } from 'solid-js'
|
||||
import AudioPlayer from './AudioPlayer/AudioPlayer'
|
||||
import type { Author, Shout } from '../../graphql/types.gen'
|
||||
import MD from './MD'
|
||||
import { SharePopup } from './SharePopup'
|
||||
|
@ -93,13 +95,8 @@ export const FullArticle = (props: ArticleProps) => {
|
|||
}, 'bookmark')
|
||||
}
|
||||
|
||||
const body = createMemo(() => props.article.body)
|
||||
|
||||
const media = createMemo(() => {
|
||||
const mi = JSON.parse(props.article.media || '[]')
|
||||
console.debug('!!! media items', mi)
|
||||
return mi
|
||||
})
|
||||
const media = createMemo(() => JSON.parse(props.article.media || '[]'))
|
||||
const body = createMemo(() => props.article.body || '')
|
||||
|
||||
const commentsRef: { current: HTMLDivElement } = { current: null }
|
||||
const scrollToComments = () => {
|
||||
|
@ -144,7 +141,7 @@ export const FullArticle = (props: ArticleProps) => {
|
|||
<div class="wide-container">
|
||||
<div class="row">
|
||||
<article class="col-md-16 col-lg-14 col-xl-12 offset-md-5">
|
||||
<div class={styles.shoutHeader}>
|
||||
<div class={styles.shoutTopic}>
|
||||
<Show when={mainTopic()}>
|
||||
<div class={styles.shoutTopic}>
|
||||
<a
|
||||
|
@ -156,48 +153,47 @@ export const FullArticle = (props: ArticleProps) => {
|
|||
</div>
|
||||
</Show>
|
||||
|
||||
<h1>{props.article.title}</h1>
|
||||
<Show when={props.article.subtitle}>
|
||||
<h4>{capitalize(props.article.subtitle, false)}</h4>
|
||||
</Show>
|
||||
<div class={styles.shoutHeader}>
|
||||
<div>
|
||||
<h1>{props.article.title}</h1>
|
||||
<div>
|
||||
<div class={styles.shoutAuthor}>
|
||||
<For each={props.article.authors}>
|
||||
{(a: Author, index) => (
|
||||
<>
|
||||
<Show when={index() > 0}>, </Show>
|
||||
<a href={getPagePath(router, 'author', { slug: a.slug })}>{a.name}</a>
|
||||
</>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
<div class={styles.shoutAuthor}>
|
||||
<For each={props.article.authors}>
|
||||
{(a: Author, index) => (
|
||||
<>
|
||||
<Show when={index() > 0}>, </Show>
|
||||
<a href={getPagePath(router, 'author', { slug: a.slug })}>{a.name}</a>
|
||||
</>
|
||||
)}
|
||||
</For>
|
||||
{/* @@TODO add album's year and genre
|
||||
<div>year</div>
|
||||
<div>genre</div> */}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* @@TODO implement image zoom */}
|
||||
<Show when={props.article.cover && props.article.layout !== 'video'}>
|
||||
<div class={styles.shoutCover}>
|
||||
<img src={imageProxy(props.article.cover)} alt="Article cover" />
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={props.article.cover && props.article.layout !== 'video'}>
|
||||
<div
|
||||
class={styles.shoutCover}
|
||||
style={{ 'background-image': `url('${imageProxy(props.article.cover)}')` }}
|
||||
/>
|
||||
|
||||
<Show when={body()}>
|
||||
<div class={styles.shoutBody}>
|
||||
<Show when={!body().startsWith('<')} fallback={<div innerHTML={body()} />}>
|
||||
<MD body={body()} />
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<Show when={media()}>
|
||||
<Show when={media().length > 0 && props.article.layout !== 'image'}>
|
||||
<div class="media-items">
|
||||
<For each={media() || []}>
|
||||
{(m: MediaItem) => (
|
||||
<div class={styles.shoutMediaBody}>
|
||||
<MediaView media={m} kind={props.article.layout} />
|
||||
<Show when={m?.body}>
|
||||
<MD body={m.body} />
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={body()}>
|
||||
<div class={styles.shoutBody}>
|
||||
<Show when={!body().startsWith('<')} fallback={<div innerHTML={body()} />}>
|
||||
<MD body={body()} />
|
||||
</Show>
|
||||
<AudioPlayer media={media()} articleSlug={props.article.slug} body={body()} />
|
||||
</div>
|
||||
</Show>
|
||||
</article>
|
||||
|
|
|
@ -1,109 +0,0 @@
|
|||
import { onMount } from 'solid-js'
|
||||
|
||||
/**
|
||||
* A utility function for drawing our line segments
|
||||
* @param {AudioContext} ctx the audio context
|
||||
* @param {number} x the x coordinate of the beginning of the line segment
|
||||
* @param {number} height the desired height of the line segment
|
||||
* @param {number} width the desired width of the line segment
|
||||
* @param {boolean} isEven whether or not the segmented is even-numbered
|
||||
*/
|
||||
const drawLineSegment = (ctx, x, height, width, isEven) => {
|
||||
ctx.lineWidth = 1 // how thick the line is
|
||||
ctx.strokeStyle = '#fff' // what color our line is
|
||||
ctx.beginPath()
|
||||
const h = isEven ? height : -height
|
||||
ctx.moveTo(x, 0)
|
||||
ctx.lineTo(x, h)
|
||||
ctx.arc(x + width / 2, h, width / 2, Math.PI, 0, isEven)
|
||||
ctx.lineTo(x + width, 0)
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters the AudioBuffer retrieved from an external source
|
||||
* @param {AudioBuffer} audioBuffer the AudioBuffer from drawAudio()
|
||||
* @returns {Array} an array of floating point numbers
|
||||
*/
|
||||
const filterData = (audioBuffer) => {
|
||||
const rawData = audioBuffer.getChannelData(0) // We only need to work with one channel of data
|
||||
const samples = 70 // Number of samples we want to have in our final data set
|
||||
const blockSize = Math.floor(rawData.length / samples) // the number of samples in each subdivision
|
||||
const filteredData = []
|
||||
for (let i = 0; i < samples; i++) {
|
||||
const blockStart = blockSize * i // the location of the first sample in the block
|
||||
let sum = 0
|
||||
for (let j = 0; j < blockSize; j++) {
|
||||
sum = sum + Math.abs(rawData[blockStart + j]) // find the sum of all the samples in the block
|
||||
}
|
||||
filteredData.push(sum / blockSize) // divide the sum by the block size to get the average
|
||||
}
|
||||
return filteredData
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes the audio data to make a cleaner illustration
|
||||
* @param {Array} filteredData the data from filterData()
|
||||
* @returns {Array} an normalized array of floating point numbers
|
||||
*/
|
||||
const normalizeData = (filteredData) => {
|
||||
const multiplier = Math.pow(Math.max(...filteredData), -1)
|
||||
return filteredData.map((n) => n * multiplier)
|
||||
}
|
||||
|
||||
interface SoundwaveProps {
|
||||
url: string
|
||||
context: AudioContext
|
||||
}
|
||||
|
||||
export const Soundwave = (props: SoundwaveProps) => {
|
||||
let canvasRef: HTMLCanvasElement
|
||||
|
||||
/**
|
||||
* Draws the audio file into a canvas element.
|
||||
* @param {Array} normalizedData The filtered array returned from filterData()
|
||||
* @returns {Array} a normalized array of data
|
||||
*/
|
||||
const draw = (normalizedData) => {
|
||||
// set up the canvas
|
||||
const canvas = canvasRef
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
const padding = 20
|
||||
canvas.width = canvas.offsetWidth * dpr
|
||||
canvas.height = (canvas.offsetHeight + padding * 2) * dpr
|
||||
const ctx = canvas.getContext('2d')
|
||||
ctx.scale(dpr, dpr)
|
||||
ctx.translate(0, canvas.offsetHeight / 2 + padding) // set Y = 0 to be in the middle of the canvas
|
||||
|
||||
// draw the line segments
|
||||
const width = canvas.offsetWidth / normalizedData.length
|
||||
// eslint-disable-next-line unicorn/no-for-loop
|
||||
for (let i = 0; i < normalizedData.length; i++) {
|
||||
const x = width * i
|
||||
let height = normalizedData[i] * canvas.offsetHeight - padding
|
||||
if (height < 0) {
|
||||
height = 0
|
||||
} else if (height > canvas.offsetHeight / 2) {
|
||||
height = height - canvas.offsetHeight / 2
|
||||
}
|
||||
drawLineSegment(ctx, x, height, width, (i + 1) % 2)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves audio from an external source, the initializes the drawing function
|
||||
* @param {AudioContext} audioContext the audio context
|
||||
* @param {String} url the url of the audio we'd like to fetch
|
||||
*/
|
||||
const drawAudio = (audioContext, url) => {
|
||||
fetch(url)
|
||||
.then((response) => response.arrayBuffer())
|
||||
.then((arrayBuffer) => audioContext.decodeAudioData(arrayBuffer))
|
||||
.then((audioBuffer) => draw(normalizeData(filterData(audioBuffer))))
|
||||
.catch(console.error)
|
||||
}
|
||||
onMount(() => {
|
||||
drawAudio(props.context, props.url)
|
||||
})
|
||||
return <canvas ref={canvasRef} />
|
||||
}
|
|
@ -78,8 +78,6 @@
|
|||
|
||||
.feedMode {
|
||||
.userpic {
|
||||
font-size: 0.8rem;
|
||||
line-height: 12px;
|
||||
min-width: 16px;
|
||||
max-width: 16px;
|
||||
}
|
||||
|
|
|
@ -196,7 +196,6 @@ export const ArticleCard = (props: ArticleCardProps) => {
|
|||
>
|
||||
<div class={styles.shoutCardType}>
|
||||
<a href={`/expo/${layout}`}>
|
||||
<Icon name={layout} class={styles.icon} />
|
||||
<Icon name={layout} class={clsx(styles.icon, styles.iconHover)} />
|
||||
</a>
|
||||
</div>
|
||||
|
|
Loading…
Reference in New Issue
Block a user