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:
Arkadzi Rakouski 2023-07-01 16:05:59 +03:00 committed by GitHub
parent 09bf522ab7
commit a18b6b9e6d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 758 additions and 229 deletions

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

View 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

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

View File

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

View File

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

View 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;
}

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

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

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

View File

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

View File

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

View File

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

View File

@ -78,8 +78,6 @@
.feedMode {
.userpic {
font-size: 0.8rem;
line-height: 12px;
min-width: 16px;
max-width: 16px;
}

View File

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