diff --git a/MusicPlayer/cobie1818/README.md b/MusicPlayer/cobie1818/README.md new file mode 100644 index 000000000..60a433aa6 --- /dev/null +++ b/MusicPlayer/cobie1818/README.md @@ -0,0 +1,64 @@ +# SoundSpace Music Player + +A responsive music player built for JavaScript Mini Projects. +Contribution related to issue #958. + +## Technologies + +- HTML5 audio +- CSS with a responsive layout +- Vanilla JavaScript +- No external libraries, API keys, or build tools required + +## Run locally + +Open index.html in a modern browser, or serve this folder using +the VS Code Live Server extension. + +## How to use + +1. Select Add audio files and choose audio files from your device. +2. Use Ctrl or Shift in the file picker to select multiple files. +3. Click Play to begin playback. +4. Use Previous, Next, or a playlist button to select a track. +5. Adjust the track position and volume using the sliders. + +Adding a new selection replaces the playlist. +Files are processed locally and are not uploaded. +The playlist is cleared when the page reloads. + +## Features + +- Play and pause +- Previous and next track navigation with wraparound +- Automatic advancement until the end of the playlist +- Clickable playlist with the selected track highlighted +- Track seeking and elapsed time display +- Volume control +- Responsive layout +- Keyboard-operable buttons and labeled controls +- Visible keyboard focus and playback status messages +- Error feedback for files the browser cannot play + +## Manual testing checklist + +- Add one file and test play, pause, seeking, and volume. +- Add multiple files and test previous, next, and track selection. +- Confirm navigation wraps around at the playlist boundaries. +- Confirm playback advances when a track ends. +- Confirm playback stops after the final track. +- Replace the playlist while music is playing. +- Try an unsupported or damaged audio file. +- Test keyboard navigation using Tab, Enter, Space, and arrow keys. +- Check the layout at phone, tablet, and desktop widths. +- Check the browser console for unexpected errors. + +## Limitations + +Supported audio formats depend on the browser. +Audio files are supplied by the user; no songs are bundled. +No automated test suite is included in this contribution. + +## Screenshot + +![SoundSpace music player with a loaded playlist](screenshot.png) \ No newline at end of file diff --git a/MusicPlayer/cobie1818/index.html b/MusicPlayer/cobie1818/index.html new file mode 100644 index 000000000..8ae7dee2d --- /dev/null +++ b/MusicPlayer/cobie1818/index.html @@ -0,0 +1,59 @@ + + + + + + SoundSpace Music Player + + + + +
+
+

YOUR MUSIC. YOUR SPACE.

+

SoundSpace

+

A simple player for your favorite tracks.

+
+ +
+ +

No track selected

+

Choose audio files to get started.

+ + + +

Files stay on your device. Adding files replaces the playlist.

+ + + +
+ + +
+ 0:00 + 0:00 +
+
+ +
+ + + +
+ +
+ + +
+
+ +
+

Your playlist

+

Your added tracks will appear here.

+
    +
    +
    + + \ No newline at end of file diff --git a/MusicPlayer/cobie1818/screenshot.png b/MusicPlayer/cobie1818/screenshot.png new file mode 100644 index 000000000..85a37a3ef Binary files /dev/null and b/MusicPlayer/cobie1818/screenshot.png differ diff --git a/MusicPlayer/cobie1818/script.js b/MusicPlayer/cobie1818/script.js new file mode 100644 index 000000000..407de7814 --- /dev/null +++ b/MusicPlayer/cobie1818/script.js @@ -0,0 +1,194 @@ +"use strict"; + +const audio = document.getElementById("audio"); +const filesInput = document.getElementById("files"); +const title = document.getElementById("now-playing"); +const status = document.getElementById("status"); +const playButton = document.getElementById("play"); +const previousButton = document.getElementById("previous"); +const nextButton = document.getElementById("next"); +const seek = document.getElementById("seek"); +const volume = document.getElementById("volume"); +const elapsed = document.getElementById("elapsed"); +const duration = document.getElementById("duration"); +const playlist = document.getElementById("playlist"); +const empty = document.getElementById("empty"); + +let tracks = []; +let currentIndex = -1; +let playbackRequest = 0; + +audio.volume = Number(volume.value); + +function formatTime(seconds) { + if (!Number.isFinite(seconds) || seconds < 0) { + return "0:00"; + } + + const minutes = Math.floor(seconds / 60); + const remainder = Math.floor(seconds % 60); + return `${minutes}:${String(remainder).padStart(2, "0")}`; +} + +function updateTimeline() { + const hasDuration = Number.isFinite(audio.duration) + && audio.duration > 0; + + seek.disabled = !hasDuration; + seek.max = hasDuration ? audio.duration : 100; + seek.value = hasDuration ? audio.currentTime : 0; + elapsed.textContent = formatTime(audio.currentTime); + duration.textContent = formatTime(audio.duration); + seek.setAttribute( + "aria-valuetext", + `${formatTime(audio.currentTime)} of ${formatTime(audio.duration)}` + ); +} + +function highlightTrack() { + const buttons = playlist.querySelectorAll("button"); + + buttons.forEach((button, index) => { + if (index === currentIndex) { + button.setAttribute("aria-current", "true"); + } else { + button.removeAttribute("aria-current"); + } + }); +} + +async function startPlayback() { + if (currentIndex < 0) return; + + const request = ++playbackRequest; + + try { + await audio.play(); + } catch (error) { + // Ignore a request interrupted by changing or pausing a track. + if (request !== playbackRequest || error.name === "AbortError") { + return; + } + + status.textContent = + "Unable to play this file. Try another audio file."; + } +} + +function selectTrack(index, autoplay = false) { + if (!tracks.length) return; + + playbackRequest++; + audio.pause(); + + // Wrap around when moving beyond either end of the playlist. + currentIndex = (index + tracks.length) % tracks.length; + audio.src = tracks[currentIndex].url; + audio.load(); + + title.textContent = tracks[currentIndex].name; + status.textContent = "Ready to play."; + playButton.textContent = "Play"; + playButton.disabled = false; + previousButton.disabled = tracks.length < 2; + nextButton.disabled = tracks.length < 2; + + updateTimeline(); + highlightTrack(); + + if (autoplay) startPlayback(); +} + +filesInput.addEventListener("change", () => { + const selectedFiles = Array.from(filesInput.files); + if (!selectedFiles.length) return; + + playbackRequest++; + audio.pause(); + audio.removeAttribute("src"); + audio.load(); + + // Release the old file references before replacing the playlist. + tracks.forEach((track) => URL.revokeObjectURL(track.url)); + + tracks = selectedFiles.map((file) => ({ + name: file.name, + url: URL.createObjectURL(file) + })); + + playlist.replaceChildren(); + empty.hidden = true; + + tracks.forEach((track, index) => { + const item = document.createElement("li"); + const button = document.createElement("button"); + + button.type = "button"; + button.textContent = track.name; + button.addEventListener("click", () => selectTrack(index, true)); + + item.appendChild(button); + playlist.appendChild(item); + }); + + selectTrack(0); + filesInput.value = ""; +}); + +playButton.addEventListener("click", () => { + if (audio.paused) { + startPlayback(); + } else { + playbackRequest++; + audio.pause(); + } +}); + +previousButton.addEventListener("click", () => { + selectTrack(currentIndex - 1, true); +}); + +nextButton.addEventListener("click", () => { + selectTrack(currentIndex + 1, true); +}); + +audio.addEventListener("play", () => { + playButton.textContent = "Pause"; + status.textContent = "Playing."; +}); + +audio.addEventListener("pause", () => { + playButton.textContent = "Play"; + if (currentIndex >= 0) status.textContent = "Paused."; +}); + +audio.addEventListener("ended", () => { + if (currentIndex < tracks.length - 1) { + selectTrack(currentIndex + 1, true); + } else { + status.textContent = "Playlist finished."; + } +}); + +audio.addEventListener("error", () => { + if (!audio.getAttribute("src")) return; + + playButton.textContent = "Play"; + status.textContent = + "This file could not be loaded. Choose another track or file."; +}); + +audio.addEventListener("loadedmetadata", updateTimeline); +audio.addEventListener("durationchange", updateTimeline); +audio.addEventListener("timeupdate", updateTimeline); + +seek.addEventListener("input", () => { + if (Number.isFinite(audio.duration) && audio.duration > 0) { + audio.currentTime = Number(seek.value); + updateTimeline(); + } +}); + +volume.addEventListener("input", () => { + audio.volume = Number(volume.value); +}); \ No newline at end of file diff --git a/MusicPlayer/cobie1818/style.css b/MusicPlayer/cobie1818/style.css new file mode 100644 index 000000000..1972946a4 --- /dev/null +++ b/MusicPlayer/cobie1818/style.css @@ -0,0 +1,199 @@ +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100vh; + padding: 32px 16px; + background: #101521; + color: #f3f5fa; + font-family: Arial, sans-serif; + line-height: 1.5; +} + +.player { + width: 100%; + max-width: 580px; + margin: auto; + padding: 32px; + background: #1b2333; + border: 1px solid #39465d; + border-radius: 24px; + box-shadow: 0 20px 50px #0005; +} + +header { + text-align: center; +} + +h1 { + margin: 8px 0; + font-size: 2.4rem; +} + +h2 { + font-size: 1.2rem; + overflow-wrap: anywhere; +} + +p { + color: #c4ccdc; +} + +.eyebrow { + color: #99e6d4; + font-size: 0.75rem; + letter-spacing: 2px; +} + +.artwork { + display: grid; + place-items: center; + width: 160px; + height: 160px; + margin: 28px auto; + border-radius: 32px; + background: linear-gradient(135deg, #8de1cc, #9bafff); + color: #142137; + font-size: 5rem; +} + +#now-playing, +#status { + text-align: center; +} + +label { + display: block; + margin-bottom: 8px; + font-weight: bold; +} + +input[type="file"] { + width: 100%; + min-width: 0; + font: inherit; +} + +input::file-selector-button { + padding: 10px; + margin-right: 8px; + border: 0; + border-radius: 8px; + cursor: pointer; +} + +.hint { + font-size: 0.85rem; +} + +.timeline, +.volume-control { + margin-top: 24px; +} + +input[type="range"] { + width: 100%; + min-height: 28px; + margin: 0; + accent-color: #99e6d4; +} + +.times { + display: flex; + justify-content: space-between; + color: #c4ccdc; + font-size: 0.9rem; +} + +.controls { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-top: 20px; +} + +button { + min-height: 44px; + padding: 10px 14px; + border: 1px solid #667895; + border-radius: 10px; + background: #28364d; + color: #fff; + font: inherit; + cursor: pointer; +} + +.controls button { + flex: 1; +} + +button.primary { + background: #99e6d4; + color: #10241f; + border-color: #99e6d4; + font-weight: bold; +} + +button:hover:not(:disabled) { + filter: brightness(1.15); +} + +button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +:focus-visible { + outline: 3px solid #ffdb85; + outline-offset: 4px; +} + +.playlist-section { + margin-top: 28px; + border-top: 1px solid #39465d; + padding-top: 16px; +} + +#playlist { + padding-left: 24px; +} + +#playlist li { + margin-bottom: 10px; +} + +#playlist button { + width: 100%; + text-align: left; + overflow-wrap: anywhere; +} + +#playlist button[aria-current="true"] { + border: 2px solid #99e6d4; + background: #304b4b; +} + +@media (max-width: 400px) { + body { + padding: 12px; + } + + .player { + padding: 20px 16px; + } + + h1 { + font-size: 2rem; + } + + .controls { + gap: 6px; + } + + .controls button { + padding: 10px 8px; + font-size: 0.9rem; + } +} \ No newline at end of file