diff --git a/app/handlers/apis/history.go b/app/handlers/apis/history.go index 34d9bdf..b4e34e3 100644 --- a/app/handlers/apis/history.go +++ b/app/handlers/apis/history.go @@ -6,10 +6,13 @@ import ( "dankmuzikk/handlers" "dankmuzikk/log" "dankmuzikk/services/history" + "dankmuzikk/views/components/playlist" "dankmuzikk/views/components/song" "fmt" "net/http" "strconv" + + "github.com/a-h/templ" ) type historyApi struct { @@ -43,8 +46,12 @@ func (h *historyApi) HandleGetMoreHistoryItems(w http.ResponseWriter, r *http.Re } outBuf := bytes.NewBuffer([]byte{}) - for _, s := range recentPlays { - song.Song(s, []string{"Played " + s.AddedAt}, nil, entities.Playlist{}). + for idx, s := range recentPlays { + song.Song(s, []string{"Played " + s.AddedAt}, + []templ.Component{ + playlist.PlaylistsPopup((idx+1)*page, s.YtId), + }, + entities.Playlist{}). Render(r.Context(), outBuf) } diff --git a/app/services/youtube/search/search_scraper.go b/app/services/youtube/search/search_scraper.go index 62a9022..3c004cd 100644 --- a/app/services/youtube/search/search_scraper.go +++ b/app/services/youtube/search/search_scraper.go @@ -12,9 +12,9 @@ import ( ) var ( - pat0 = regexp.MustCompile(`"innertubeApiKey":"([^"]*)`) - pat = regexp.MustCompile(`ytInitialData[^{]*(.*?);\s*<\/script>`) - pat2 = regexp.MustCompile(`ytInitialData"[^{]*(.*);\s*window\["ytInitialPlayerResponse"\]`) + keyPattern = regexp.MustCompile(`"innertubeApiKey":"([^"]*)`) + dataPattern = regexp.MustCompile(`ytInitialData[^{]*(.*?);\s*<\/script>`) + dataPattern2 = regexp.MustCompile(`ytInitialData"[^{]*(.*);\s*window\["ytInitialPlayerResponse"\]`) ) type videoResult struct { @@ -95,15 +95,15 @@ func search(q string) ([]videoResult, error) { Parser: "json_format", Key: "", } - key := pat0.FindSubmatch(respBody) + key := keyPattern.FindSubmatch(respBody) jojo.Key = string(key[1]) - matches := pat.FindSubmatch(respBody) + matches := dataPattern.FindSubmatch(respBody) if len(matches) > 1 { jojo.Parser += ".object_var" } else { jojo.Parser += ".original" - matches = pat2.FindSubmatch(respBody) + matches = dataPattern2.FindSubmatch(respBody) } data := ytSearchData{} err = json.Unmarshal(matches[1], &data) @@ -134,7 +134,7 @@ func search(q string) ([]videoResult, error) { return resSuka, nil } -// ScraperSearch is a scrapper enabled YouTube search, using the search service under ~/ytscraper +// ScraperSearch is a scrapper enabled YouTube search. type ScraperSearch struct{} func (y *ScraperSearch) Search(query string) (results []entities.Song, err error) { diff --git a/app/static/css/refresher.css b/app/static/css/refresher.css new file mode 100644 index 0000000..57a5fce --- /dev/null +++ b/app/static/css/refresher.css @@ -0,0 +1,77 @@ +/* I herby admit that this code is a copy-pasta from https://developer.chrome.com/blog/overscroll-behavior/ */ + +body.refreshing #main-contents, +body.refreshing header { + filter: blur(1px); + touch-action: none; +} + +body.refreshing .refresher { + transform: translate3d(0, 150%, 0) scale(1); + z-index: 50; + visibility: visible; +} + +.refresher { + pointer-events: none; + --refresh-width: 55px; + background: var(--secondary-color); + width: var(--refresh-width); + height: var(--refresh-width); + border-radius: 50%; + position: absolute; + left: calc(50% - var(--refresh-width) / 2); + padding: 8px; + box-shadow: + 0 2px 2px 0 rgba(0, 0, 0, 0.14), + 0 1px 5px 0 rgba(0, 0, 0, 0.12), + 0 3px 1px -2px rgba(0, 0, 0, 0.2); + transition: all 0.5s cubic-bezier(0, 0, 0.2, 1); + will-change: transform; + display: inline-flex; + justify-content: space-evenly; + align-items: center; + visibility: hidden; +} + +body.refreshing .refresher.shrink { + transform: translate3d(0, 150%, 0) scale(0); + opacity: 0; +} + +.refresher.done { + transition: none; +} + +.loading-bar { + background-color: var(--primary-color); + width: 4px; + height: 18px; + border-radius: 4px; + animation: loading 0.81s ease-in-out infinite; +} + +.loading-bar:nth-child(1) { + animation-delay: 0; +} +.loading-bar:nth-child(2) { + animation-delay: 0.09s; +} +.loading-bar:nth-child(3) { + animation-delay: 0.18s; +} +.loading-bar:nth-child(4) { + animation-delay: 0.27s; +} + +@keyframes loading { + 0% { + transform: scale(1); + } + 20% { + transform: scale(1, 2.2); + } + 40% { + transform: scale(1); + } +} diff --git a/app/static/js/player.js b/app/static/js/player.js index 642dbd1..f1a2a32 100644 --- a/app/static/js/player.js +++ b/app/static/js/player.js @@ -376,7 +376,8 @@ function playlister(state) { ? 0 : state.currentSongIdx + 1; const songToPlay = state.playlist.songs[state.currentSongIdx]; - playSongFromPlaylist(songToPlay.yt_id, state.playlist); + await highlightSongInPlaylist(songToPlay.yt_id, state.playlist); + await playSong(songToPlay); __setSongInPlaylistStyle(songToPlay.yt_id, state.playlist); }; @@ -415,7 +416,8 @@ function playlister(state) { ? state.playlist.songs.length - 1 : state.currentSongIdx - 1; const songToPlay = state.playlist.songs[state.currentSongIdx]; - playSongFromPlaylist(songToPlay.yt_id, state.playlist); + await highlightSongInPlaylist(songToPlay.yt_id, state.playlist); + await playSong(songToPlay); __setSongInPlaylistStyle(songToPlay.yt_id, state.playlist); }; @@ -645,6 +647,46 @@ async function playSingleSongNext(song) { alert(`Playing ${song.title} next!`); } +/** + * @param {Playlist} playlist + */ +async function playPlaylistNext(playlist) { + if (!playlist || !playlist.songs || playlist.songs.length === 0) { + alert("Can't do that!"); + return; + } + if (playerState.playlist.songs.length === 0) { + playSongFromPlaylist(playlist.songs[0].yt_id, playlist); + return; + } + playerState.playlist.songs.splice( + playerState.currentSongIdx + 1, + 0, + ...playlist.songs.map((s) => { + return { ...s, votes: 1 }; + }), + ); + playerState.playlist.title = `${playerState.playlist.title} + ${playlist.title}`; + alert(`Playing ${playlist.title} next!`); +} + +/** + * @param {Playlist} playlist + */ +async function appendPlaylistToCurrentQueue(playlist) { + if (!playlist || !playlist.songs || playlist.songs.length === 0) { + alert("Can't do that!"); + return; + } + if (playerState.playlist.songs.length === 0) { + playSongFromPlaylist(playlist.songs[0].yt_id, playlist); + return; + } + playerState.playlist.songs.push(...playlist.songs); + playerState.playlist.title = `${playerState.playlist.title} + ${playlist.title}`; + alert(`Playing ${playlist.title} next!`); +} + /** * @param {string} songYtId * @param {Playlist} playlist @@ -684,12 +726,6 @@ async function playSongFromPlaylist(songYtId, playlist) { * @param {Song} song */ function appendSongToCurrentQueue(song) { - if ( - playerState.playlist.songs.findIndex((s) => s.yt_id === song.yt_id) !== -1 - ) { - alert(`${song.title} exists in the queue!`); - return; - } if (playerState.playlist.songs.length === 0) { playSingleSong(song); return; @@ -917,8 +953,10 @@ window.Player.hidePlayer = hide; window.Player.playSingleSong = playSingleSong; window.Player.playSingleSongNext = playSingleSongNext; window.Player.playSongFromPlaylist = playSongFromPlaylist; +window.Player.playPlaylistNext = playPlaylistNext; window.Player.removeSongFromPlaylist = removeSongFromPlaylist; window.Player.addSongToQueue = appendSongToCurrentQueue; +window.Player.appendPlaylistToCurrentQueue = appendPlaylistToCurrentQueue; window.Player.stopMuzikk = stopMuzikk; window.Player.expand = () => expand(); window.Player.collapse = () => collapse(); diff --git a/app/static/js/refresher.js b/app/static/js/refresher.js new file mode 100644 index 0000000..9e03bd5 --- /dev/null +++ b/app/static/js/refresher.js @@ -0,0 +1,62 @@ +/* I herby admit that this code is a copy-pasta from https://developer.chrome.com/blog/overscroll-behavior/ */ + +"use strict"; + +const mainContentsEl = document.getElementById("main-contents"); +let _startY = 0; + +async function simulateRefreshAction() { + const sleep = (timeout) => + new Promise((resolve) => setTimeout(resolve, timeout)); + + const transitionEnd = function (propertyName, node) { + return new Promise((resolve) => { + function callback(e) { + e.stopPropagation(); + if (e.propertyName === propertyName) { + node.removeEventListener("transitionend", callback); + resolve(e); + } + } + node.addEventListener("transitionend", callback); + }); + }; + + const refresher = document.querySelector(".refresher"); + + document.body.classList.add("refreshing"); + await sleep(500); + + refresher.classList.add("shrink"); + await transitionEnd("transform", refresher); + refresher.classList.add("done"); + + refresher.classList.remove("shrink"); + document.body.classList.remove("refreshing"); + await sleep(0); // let new styles settle. + refresher.classList.remove("done"); +} + +document.body.addEventListener( + "touchstart", + (e) => { + _startY = e.touches[0].pageY; + }, + { passive: true }, +); + +document.body.addEventListener( + "touchmove", + async (e) => { + const y = e.touches[0].pageY; + if ( + document.scrollingElement.scrollTop === 0 && + y > _startY + 150 && + !document.body.classList.contains("refreshing") + ) { + await simulateRefreshAction(); + await updateMainContent(window.location.pathname); + } + }, + { passive: true }, +); diff --git a/app/static/js/router.js b/app/static/js/router.js index e316c9b..537e45f 100644 --- a/app/static/js/router.js +++ b/app/static/js/router.js @@ -27,6 +27,35 @@ window.addEventListener("load", () => { updateActiveNavLink(); }); +/** + * @param {string} path the requested path to update. + */ +async function updateMainContent(path) { + Utils.showLoading(); + await fetch(path + "?no_layout=true") + .then((res) => res.text()) + .then((page) => { + mainContentsEl.innerHTML = page; + }) + .catch(() => { + window.location.reload(); + }) + .finally(() => { + Utils.hideLoading(); + updateActiveNavLink(); + }); +} + +window.addEventListener("popstate", async (e) => { + const mainContentsEl = document.getElementById("main-contents"); + if (!!mainContentsEl && !!e.target.location.pathname) { + e.stopImmediatePropagation(); + e.preventDefault(); + await updateMainContent(e.target.location.pathname); + return; + } +}); + document.addEventListener("htmx:afterRequest", function (e) { if (!!e.detail && !!e.detail.xhr) { const newTitle = e.detail.xhr.getResponseHeader("HX-Title"); @@ -36,4 +65,4 @@ document.addEventListener("htmx:afterRequest", function (e) { } }); -window.Router = { updateActiveNavLink }; +window.Router = { updateActiveNavLink, updateMainContent }; diff --git a/app/views/components/menus/mobile_menu.templ b/app/views/components/menus/mobile_menu.templ index df79df4..958dee0 100644 --- a/app/views/components/menus/mobile_menu.templ +++ b/app/views/components/menus/mobile_menu.templ @@ -39,31 +39,36 @@ templ MobileMenu(id, title string, button, child templ.Component) { .mobile-menu-collapsed { height: 0; max-height: 0; - } - .mobile-menu-exapnded { - min-height: 200px; - max-height: 500px; + min-height: 0; } } script toggleMobileMenu(id string) { const popover = document.getElementById(`mobile-menu-${id}`); - popover.classList.toggle("mobile-menu-exapnded"); + if (popover.classList.contains("mobile-menu-collapsed")) { + const player = document.getElementById("ze-player"); + const rect = popover.getBoundingClientRect(); + popover.style.bottom = `-${ + (window.innerHeight-(rect.y+rect.height)) - + 65 - + (!player.classList.contains("hidden")? + player.getBoundingClientRect().height + 5: + 0) + }px` + popover.style.height = ( + popover.children[0].getBoundingClientRect().height + 4 + + popover.children[1].getBoundingClientRect().height + ).toString() + "px" ; + popover.style.maxHeight = "500px"; + popover.style.minHeight = "200px"; - const player = document.getElementById("ze-player"); - console.log(player.getBoundingClientRect()) + popover.classList.remove("mobile-menu-collapsed"); + } else { + popover.classList.add("mobile-menu-collapsed"); + popover.style.height = 0; + popover.style.maxHeight = 0; + popover.style.minHeight = 0; + } - const rect = popover.getBoundingClientRect(); - popover.style.bottom = `-${ - (window.innerHeight-(rect.y+rect.height)) - - 65 - - (!player.classList.contains("hidden")? - player.getBoundingClientRect().height + 5: - 0) - }px` - popover.style.height = ( - popover.children[0].getBoundingClientRect().height + 4 + - popover.children[1].getBoundingClientRect().height - ).toString() + "px" ; } diff --git a/app/views/components/menus/popover.templ b/app/views/components/menus/popover.templ index c789757..7f688e3 100644 --- a/app/views/components/menus/popover.templ +++ b/app/views/components/menus/popover.templ @@ -39,6 +39,7 @@ script toggleTheThing(id string) { if (popover.style.display !== "block") { popover.style.display = "block"; + popover.scrollIntoView({ block: "nearest" }); } else { popover.style.display = "none"; } diff --git a/app/views/components/playlist/options.templ b/app/views/components/playlist/options.templ index b752a24..41a6d94 100644 --- a/app/views/components/playlist/options.templ +++ b/app/views/components/playlist/options.templ @@ -8,24 +8,67 @@ import ( "dankmuzikk/views/icons" ) -templ PlaylistsOptionsPopover(playlist entities.Playlist) { - @menus.Popover("playlist-"+playlist.PublicId, "Playlist options", icons.Options(), playlistOptions(playlist)) +templ PlaylistsOptions(playlist entities.Playlist) { + if isMobile, ok := ctx.Value("is-mobile").(bool); ok && isMobile { + @menus.MobileMenu("playlist-"+playlist.PublicId, "Playlist's options", icons.Options(), playlistOptions(playlist)) + } else { + @menus.Popover("playlist-"+playlist.PublicId, "Playlist's options", icons.Options(), playlistOptions(playlist)) + } } templ playlistOptions(playlist entities.Playlist) {
- Public +
+ Sharable
} + +script playPlaylistNext(pl entities.Playlist) { + Player.playPlaylistNext(pl) +} + +script addToQueue(pl entities.Playlist) { + Player.appendPlaylistToCurrentQueue(pl) +} + +script copyLink(isPublic bool, plPubId string) { + window.Utils.copyTextToClipboard(`${location.protocol}//${location.host}/playlist/${plPubId}`) + if (isPublic) { + alert("Playlist's links was copied!"); + } else { + alert("Playlist's links was copied!\nMake sure to make it public before sharing the link 😁") + } +} diff --git a/app/views/components/playlist/popup.templ b/app/views/components/playlist/popup.templ index ef44f21..e7b35d8 100644 --- a/app/views/components/playlist/popup.templ +++ b/app/views/components/playlist/popup.templ @@ -9,7 +9,7 @@ import ( ) templ PlaylistsPopup(index int, songId string) { - @menus.Popup(fmt.Sprint(index), "Add to playlist", popupButton(songId), playlistSelector(songId)) + @menus.Popup(fmt.Sprint(index), "Add to playlist", popupButton(), playlistSelector(songId)) } templ playlistSelector(songId string) { @@ -19,11 +19,14 @@ templ playlistSelector(songId string) { "min-w-[350px]", "bg-accent-trans-30", "backdrop-blur-xl", "p-3", "text-secondary", "rounded-b-[10px]", "rounded-l-[10px]", } + hx-get={ "/api/playlist/all?song-id=" + songId } + hx-swap="outerHTML" + hx-trigger="intersect" > -Added on { song.AddedAt }
if song.PlayTimes == 1 {Played once
diff --git a/app/views/components/song/song.templ b/app/views/components/song/song.templ index 30da0e6..24c0d41 100644 --- a/app/views/components/song/song.templ +++ b/app/views/components/song/song.templ @@ -91,10 +91,12 @@ templ Song(s entities.Song, additionalData []string, additionalOptions []templ.C templ options(song entities.Song, additionalOptions []templ.Component) {