diff --git a/README.md b/README.md index dbf2992..fc55187 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,7 @@ And that's it! you can upload the files to your website, or zip them up and uplo `file` > The name of the file in the /media folder +> Not required if entry is set to "locked" \ `title` (optional) @@ -121,12 +122,48 @@ And that's it! you can upload the files to your website, or zip them up and uplo `type` (optional) > The type of the media. > Can be either `"audio"` or `"video"` -> Defaults to "audio" +> Defaults to `"audio"` \ `info` (optional) > Extra text for this file, such as for lyrics or artist attributions. This will be optionally shown over/under the cover image, or togglable below the track. +\ +`locked` (optional) +> If `true`, sets this file as "locked" preventing it from being playable. +> Can be either `true` or `false` +> Defaults to `false` +> Enable this if e.g. you want to show that this file would be available in the purchased download of your album. +> Note: the `file` field is not required if this is enabled, you shouldn't include the file in the player. + +***preview options*** + +*a preview is a section of a full track, used if e.g. you want to include a snippet of a track that would be available in the purchased download of your album.* +*note: it is highly recommended that the file you use for a preview track is clipped to the region you want to preview, because the file can be accessed by users. Scritch will support the file in either case, however.* + +> e.g. `{"file": "track1.mp3", "title": "Track 1", previewStart: 12, previewEnd: 42, originalDuration: "1:20"}` + +\ +`previewStart` (optional, required for preview) +> The start time for the preview section of the full track. +> Can be a number of seconds or an H:M:S string, e.g. `125.3`, `"2:05"`, `"00:21:13.5"` are all valid. + +\ +`previewEnd` (optional, required for preview) +> The end time for the preview section of the full track. +> Can be a number of seconds or an H:M:S string, e.g. `125.3`, `"2:05"`, `"00:21:13.5"` are all valid. + +\ +`originalDuration` (optional, required for preview) +> The duration of the full (unclipped) track. +> Can be a number of seconds or an H:M:S string, e.g. `125.3`, `"2:05"`, `"00:21:13.5"` are all valid. + +\ +`previewFade` (optional) +> If `true`, the player will automatically fade the audio at the start and end of the preview section. +> Can be either `true` or `false` +> Defaults to `true` + ----- **theme options** @@ -166,6 +203,14 @@ And that's it! you can upload the files to your website, or zip them up and uplo `primaryAltTextColor` > Color of e.g. the track numbers and duration, and the track info button +\ +`previewStripeColor1` +> First color of the striped bar on a preview track. + +\ +`previewStripeColor2` +> Second color of the striped bar on a preview track. + \ `linkColor` > Color of links in text diff --git a/index.html b/index.html index 64a3255..20fb3f3 100644 --- a/index.html +++ b/index.html @@ -17,7 +17,9 @@ --secondaryColor: #000000; --highlightColor: rgba(131, 131, 131, 0.3); --primaryTextColor: #ffffff; - --primaryAltTextColor: #838383; + --primaryAltTextColor: hsl(0, 0%, 51%); + --previewStripeColor1: #3f3f3f; + --previewStripeColor2: #2c2c2c; --linkColor: #838383; --font: sans-serif; } @@ -54,7 +56,7 @@ #mainContainer { position: relative; display: grid; - justify-content: space-around; + justify-content: space-evenly; width: 100%; max-width: 960px; left: 0; @@ -112,6 +114,8 @@ #mainContainer #mediaColumn { grid-row: 2; grid-column: 1; + position: relative; + top: 0; } } @@ -122,11 +126,16 @@ grid-row: 1/3; grid-column: 2; flex-direction: column; - position: relative; + position: sticky; + top: 10px; + margin-left: auto; + margin-right: auto; } #mainContainer.vertical #mediaColumn { grid-row: 2; grid-column: 1; + position: relative; + top: 0; } #mainContainer.title-span #mediaColumn { grid-row: 2/3; @@ -303,6 +312,7 @@ flex-wrap: wrap; flex: 1 0; align-items: center; + position: relative; } #controls .title { @@ -319,6 +329,7 @@ display: -webkit-box; -webkit-box-orient: vertical; user-select: none; + flex-basis: 8px; } #controls .riser { @@ -330,6 +341,7 @@ align-self: end; color: var(--primaryAltTextColor); user-select: none; + text-align: right; } #controls #scrubberTrackContainer { @@ -341,6 +353,26 @@ cursor: pointer; touch-action: none; } + #controls #scrubberTrackFull { + width: 100%; + height: 100%; + position: relative; + background: repeating-linear-gradient( 135deg, var(--previewStripeColor1), var(--previewStripeColor1) 8px, var(--previewStripeColor2) 8px, var(--previewStripeColor2) 16px); + border-radius: 12px; + } + #controls #scrubberTrackContainer #scrubberTrackPreview { + width: 100%; + height: 100%; + position: relative; + } + #controls #scrubberTrackContainer.preview #scrubberTrackPreview { + border: 2px solid var(--backgroundColor); + box-sizing: content-box; + top: -2px; + left: -2px; + margin-right: -4px; + border-radius: 12px; + } #controls #scrubberTrack { background-color: var(--primaryAltColor); overflow: hidden; @@ -388,6 +420,9 @@ align-items: center; user-select: none; height: 20px; + position: absolute; + right: 0; + bottom: 0; } #controls #volumeContainer #volumeTrackContainer { @@ -458,7 +493,7 @@ border-radius: 6px; } .track.locked { - opacity: 0.35; + opacity: 0.5; } .track.active:before { content: ''; @@ -573,6 +608,7 @@ color: var(--primaryAltTextColor); font-size: 14px; flex: 0 0 30px; + white-space: nowrap; } .track .spacer { flex: 1 1; @@ -667,10 +703,14 @@
____
@@ -700,6 +740,7 @@ let bigButtonEl = document.getElementById('bigButton'); let controlsEl = document.getElementById('controls'); let scrubberTrackContainerEl = document.getElementById('scrubberTrackContainer'); + let scrubberTrackPreviewEl = document.getElementById('scrubberTrackPreview'); let scrubberTrackEl = document.getElementById('scrubberTrack'); let scrubberFillEl = document.getElementById('scrubberFill'); let scrubberEl = document.getElementById('scrubber'); @@ -751,6 +792,8 @@ if(theme.secondaryColor) rootEl.style.setProperty('--secondaryColor', theme.secondaryColor); if(theme.highlightColor) rootEl.style.setProperty('--highlightColor', theme.highlightColor); if(theme.backgroundColor) rootEl.style.setProperty('--backgroundColor', theme.backgroundColor); + if(theme.previewStripeColor1) rootEl.style.setProperty('--previewStripeColor1', theme.previewStripeColor1); + if(theme.previewStripeColor2) rootEl.style.setProperty('--previewStripeColor2', theme.previewStripeColor2); if(theme.linkColor) rootEl.style.setProperty('--linkColor', theme.linkColor); if(theme.layoutStyle){ document.getElementById('mainContainer').classList.remove('vertical', 'horizontal'); @@ -764,7 +807,7 @@ document.getElementById('mainContainer').classList.remove('title-none', 'title-span'); document.getElementById('mainContainer').classList.add('title-' + theme.titleStyle); } - if(theme.contentWidth) document.getElementById('contentContainer').style.width = theme.contentWidth + 'px'; + if(theme.contentWidth) document.getElementById('contentContainer').style.maxWidth = theme.contentWidth + 'px'; if(theme.nativePlayer){ document.getElementById('audio').classList.add('native'); mediaVideoEl.setAttribute('controls', ''); @@ -849,6 +892,8 @@ updateDescription(description); updateTheme(theme); + updateTrackPreview(); + document.title = titleEl.textContent; volume = parseFloat(localStorageGet('volume')); @@ -916,6 +961,9 @@ infoContainerEl.classList.toggle('active'); }); } + if(!entry.previewFade && entry.previewFade !== false){ + entry.previewFade = true; //default true + } if(!entry.locked){ trackEl.querySelector('button').onclick = () => { loadedFirst = true; @@ -928,11 +976,51 @@ } else { loaderEl = document.createElement('audio'); } + if(entry.previewStart || entry.previewEnd){ + entry.preview = true; + } + let needsPreviewEnd = false; + if(!entry.previewEnd){ + needsPreviewEnd = true; + } + entry.originalDuration = (entry.originalDuration && fromHMS(entry.originalDuration)) || 1; + entry.previewStart = (entry.previewStart && fromHMS(entry.previewStart)) || 0; + entry.previewEnd = (entry.previewEnd && fromHMS(entry.previewEnd)) || entry.originalDuration; + entry.previewDuration = (entry.previewEnd - entry.previewStart); loaderEl.addEventListener('loadedmetadata', e => { entry.duration = e.target.duration; - trackEl.querySelector('.duration').textContent = toHMS(entry.duration); + entry.needsDuration = false; + entry.previewStart = entry.previewStart || 0; + if(needsPreviewEnd){ + entry.previewEnd = entry.duration; + } + entry.originalDuration = Math.max(entry.originalDuration, entry.duration, entry.previewEnd); + entry.previewDuration = (entry.previewEnd - entry.previewStart); + + //make assumptions about the audio start points + if(entry.duration < entry.previewDuration){ //smaller than given preview duration + //left align to preview + entry.audioStart = entry.previewStart; + } else if(entry.duration < entry.originalDuration - entry.previewStart){ //else, smaller than region between preview start and original end + //left align to preview + entry.audioStart = entry.previewStart; + } else if(entry.duration < entry.originalDuration){ //else, smaller than original duration + //right align to original + entry.audioStart = entry.originalDuration - entry.duration; + } else { //larger than original duration + //left align to original + entry.audioStart = 0; + } + entry.audioEnd = entry.audioStart + entry.duration; + + let durationText = toHMS(entry.duration); + if(entry.preview){ + durationText += ' (preview)'; + } + trackEl.querySelector('.duration').textContent = durationText; if(entry === currentEntry){ - updateScrubPosition(0); + updateTrackPreview(); + updateScrubPosition(entry.previewStart / entry.originalDuration); } }); loaderEl.addEventListener('error', e => { @@ -947,15 +1035,14 @@ }); loaderEl.volume = 0; loaderEl.src = entry.file; + entry.started = false; entry.loading = true; } }); - loadEntry(media[featureIndex]); + loadEntry(media[mod(featureIndex, media.length)]); } function loadCover(cover){ - // let el = document.createElement('img'); - // mediaImageEl.appendChild(el); let src = cover; if(!/:\/\//.test(src)){ //if not a url src = mediaDir + src; @@ -966,7 +1053,15 @@ playerEl.addEventListener('canplay', e => { removeLoading(currentEntry); - if(autoPlay){ + if(currentEntry.needsDuration){ + currentEntry.duration = playerEl.duration; + currentEntry.needsDuration = false; + } + if(autoPlay && !currentEntry.started){ + currentEntry.started = true; + if(!currentEntry.needsDuration){ + playerEl.currentTime = currentEntry.previewStart - currentEntry.audioStart; + } playerEl.play(); } if(!loadedFirst){ @@ -977,7 +1072,11 @@ setLoading(currentEntry, 0); }); playerEl.addEventListener('play', e => { + if(currentEntry.preview && playerEl.currentTime < currentEntry.previewStart - currentEntry.audioStart){ + playerEl.currentTime = currentEntry.previewStart - currentEntry.audioStart; + } bigButtonEl.classList.add('pause'); + currentEntry.started = true; currentEntry.trackEl.querySelector('button').classList.add('pause'); if(currentEntry.type === 'video'){ mediaVideoEl.play(); @@ -1011,8 +1110,20 @@ }); playerEl.addEventListener('timeupdate', e => { if(!scrubberDragged){ - let t = e.target.currentTime / currentEntry.duration; - updateScrubPosition(t); + if(currentEntry.duration && !currentEntry.needsDuration){ + if(currentEntry.started){ + let time = e.target.currentTime; + if(time > currentEntry.previewEnd - currentEntry.audioStart){ + nextTrack(false); + } else { + let t = time / currentEntry.duration; + updatePreviewVolume(t); + updateScrubPosition(t); + } + } else { + + } + } } }); playerEl.addEventListener('ended', e => { @@ -1108,6 +1219,7 @@ let scrubberDragged = false; scrubberTrackContainerEl.addEventListener('pointerdown', e => { scrubberDragged = true; + currentEntry.started = true; scrub(e.clientX); }); document.addEventListener('pointerup', e => { @@ -1135,15 +1247,63 @@ prevTrack(); }); + function updateTrackPreview(){ + if(currentEntry.needsDuration){ + return; + } + //reset to prevent compounding width calculation issues + scrubberTrackPreviewEl.style.removeProperty('margin-left'); + scrubberTrackPreviewEl.style.removeProperty('width'); + scrubberEl.style.removeProperty('left'); + scrubberFillEl.style.removeProperty('width'); + if(currentEntry.preview){ + let fullWidth = scrubberEl.parentElement.parentElement.clientWidth; + scrubberTrackPreviewEl.style.marginLeft = ((currentEntry.previewStart) / currentEntry.originalDuration) * 100 + '%'; + scrubberTrackPreviewEl.style.width = ((currentEntry.previewEnd - currentEntry.previewStart) / currentEntry.originalDuration) * 100 + '%'; + } + } + + function updatePreviewVolume(t){ + if(currentEntry.preview && currentEntry.previewFade && !muted){ + if(currentEntry.needsDuration){ + playEntry.volume = 0; + } else { + //rebase t to full (assumed) duration + t = currentEntry.audioStart + (t * currentEntry.duration); + //clip to preview + t = Math.max(currentEntry.previewStart, Math.min(currentEntry.previewEnd, t)); + let inTime = t - currentEntry.previewStart; + let outTime = currentEntry.previewEnd - t; + playerEl.volume = volume * Math.max(0, Math.min(1, inTime/1, outTime/1)); //1 second fade in/out + } + } + } + + //t: 0-1 value in relation to the actual audio start/end function updateScrubPosition(t){ - scrubPosition = t; + //rebase t to full (assumed) duration + t = currentEntry.audioStart + (t * currentEntry.duration); + //clip to preview + t = Math.max(currentEntry.previewStart, Math.min(currentEntry.previewEnd, t)); + scrubPosition = (t - currentEntry.audioStart) / currentEntry.duration; + let time = t; + //rebase + t = t / currentEntry.originalDuration; + //relative to preview region + let scrubberT = ((t * currentEntry.originalDuration) - currentEntry.previewStart) / currentEntry.previewDuration; let scrubberWidth = scrubberEl.clientWidth; let trackWidth = scrubberEl.parentElement.clientWidth; - //scrubberEl.style.left = `calc(${(t * 100).toFixed(0)}% - 8px)`; - scrubberEl.style.left = (scrubPosition * (trackWidth - scrubberWidth)) + 'px'; - scrubberFillEl.style.width = (scrubPosition * trackWidth) + 'px'; - let time = scrubPosition * currentEntry.duration; - controlsEl.querySelector('.time').textContent = `${toHMS(time)} / ${currentEntry.duration ? toHMS(currentEntry.duration) : "0:00"}`; + + scrubberEl.style.left = ((scrubberT * (trackWidth - scrubberWidth)) / trackWidth) * 100 + '%'; + scrubberFillEl.style.width = (scrubberT) * 100 + '%'; + + //let time = timePosition * currentEntry.duration; + let timeText = ''; + if(currentEntry.preview){ + timeText += '(preview)
'; + } + timeText += `${toHMS(time)} / ${currentEntry.originalDuration ? toHMS(currentEntry.originalDuration) : "0:00"}` + controlsEl.querySelector('.time').innerHTML = timeText; } function scrub(x){ @@ -1153,6 +1313,10 @@ let scrubberWidth = scrubberEl.clientWidth; let trackWidth = scrubberEl.parentElement.clientWidth; let t = Math.max(0, Math.min(1, (x - scrubberWidth/2) / (trackWidth - scrubberWidth))); + if(currentEntry.preview){ + //rebase t from preview to audio start/end + t = (t * currentEntry.previewDuration + (currentEntry.previewStart - currentEntry.audioStart)) / currentEntry.duration; + } updateScrubPosition(t); } } @@ -1189,8 +1353,8 @@ loadEntry(entry); } function prevTrack(){ - if(playerEl.currentTime > 5){ - playerEl.currentTime = 0; + if(playerEl.currentTime > (currentEntry.previewStart - currentEntry.audioStart) + 5){ + playerEl.currentTime = (currentEntry.previewStart - currentEntry.audioStart); } else { let idx = media.indexOf(currentEntry)-1; let entry = media[mod(idx, media.length)]; @@ -1244,7 +1408,15 @@ }); currentEntry = entry; if(!currentEntry.duration){ - currentEntry.duration = 0.01; + currentEntry.duration = 999; + currentEntry.needsDuration = true; + } + updateTrackPreview(); + if(entry.preview){ + scrubberTrackContainerEl.classList.add('preview'); + updatePreviewVolume(0); + } else { + scrubberTrackContainerEl.classList.remove('preview'); } Array.from(mediaContainerEl.children).forEach(el => { el.classList.remove('active'); @@ -1271,6 +1443,7 @@ mediaVideoEl.load(); } playerEl.src = entry.file; + entry.started = false; setLoading(currentEntry); entry.trackEl.classList.add('active'); controlsEl.querySelector('.title').innerHTML = entry.title; @@ -1301,7 +1474,7 @@ function toHMS(secs){ - let sec_num = parseInt(secs, 10); + let sec_num = Math.max(0, Math.round(secs)); //flooring is more correct, but rounding gives nicer results due to playback event buffering let hours = Math.floor(sec_num / 3600); let minutes = Math.floor(sec_num / 60) % 60; let seconds = sec_num % 60; @@ -1312,6 +1485,37 @@ .join(":") } + function fromHMS(hms){ + if(!hms){ + return 0; + } + let groups = (''+hms).split(':'); + if(groups.length < 1){ + return 0; + } + for(let i = 0; i < groups.length; i++){ + if(groups[i] === ''){ + groups[i] = '0'; + continue; + } + if(isNaN(groups[i])){ + return 0; + } + } + let seconds = 0; + let minutes = 0; + let hours = 0; + seconds = parseFloat(groups[groups.length-1]); + if(groups.length >= 2){ + minutes = parseFloat(groups[groups.length-2]); + } + if(groups.length >= 3){ + hours = parseFloat(groups[groups.length-3]); + } + + return (((hours * 60) + minutes) * 60) + seconds; + } + function mod(x, n){ return (((x % n) + n) % n); } diff --git a/src/fonts.css b/src/fonts.css index da4706a..9c0c425 100644 --- a/src/fonts.css +++ b/src/fonts.css @@ -1,10 +1,10 @@ @font-face { font-family: 'icons'; - src: url('fonts/icons.eot?qqpgvs'); - src: url('fonts/icons.eot?qqpgvs#iefix') format('embedded-opentype'), - url('fonts/icons.ttf?qqpgvs') format('truetype'), - url('fonts/icons.woff?qqpgvs') format('woff'), - url('fonts/icons.svg?qqpgvs#icons') format('svg'); + src: url('fonts/icons.eot?llsbwi'); + src: url('fonts/icons.eot?llsbwi#iefix') format('embedded-opentype'), + url('fonts/icons.ttf?llsbwi') format('truetype'), + url('fonts/icons.woff?llsbwi') format('woff'), + url('fonts/icons.svg?llsbwi#icons') format('svg'); font-weight: normal; font-style: normal; font-display: block; @@ -26,6 +26,39 @@ -moz-osx-font-smoothing: grayscale; } +.icon-duration-preview:before { + content: "\1f55b"; +} +.icon-duration:before { + content: "\1f553"; +} +.icon-duration-dot:before { + content: "\23f2"; +} +.icon-end:before { + content: "\e900"; +} +.icon-start:before { + content: "\23f4"; +} +.icon-cut:before { + content: "\2700"; +} +.icon-marker-help:before { + content: "\2753"; +} +.icon-marker-info:before { + content: "\1f6c8"; +} +.icon-marker-warning:before { + content: "\2757"; +} +.icon-lock-file:before { + content: "\1f513"; +} +.icon-lock:before { + content: "\1f512"; +} .icon-config:before { content: "\1f4c3"; } diff --git a/src/fonts/icons.eot b/src/fonts/icons.eot index 21c27f1..24d3e3b 100644 Binary files a/src/fonts/icons.eot and b/src/fonts/icons.eot differ diff --git a/src/fonts/icons.svg b/src/fonts/icons.svg index 5d0dfe1..4331ca4 100644 --- a/src/fonts/icons.svg +++ b/src/fonts/icons.svg @@ -15,6 +15,8 @@ + + @@ -24,8 +26,12 @@ + + + + @@ -36,8 +42,13 @@ + + + + + \ No newline at end of file diff --git a/src/fonts/icons.ttf b/src/fonts/icons.ttf index d5290e9..9ecf9b6 100644 Binary files a/src/fonts/icons.ttf and b/src/fonts/icons.ttf differ diff --git a/src/fonts/icons.woff b/src/fonts/icons.woff index aa07908..72b6a5f 100644 Binary files a/src/fonts/icons.woff and b/src/fonts/icons.woff differ