diff --git a/cmd/migrator/migrator.go b/cmd/migrator/migrator.go index cd1ac10..fa4aec6 100644 --- a/cmd/migrator/migrator.go +++ b/cmd/migrator/migrator.go @@ -20,6 +20,7 @@ func Migrate() error { new(models.PlaylistSong), new(models.PlaylistOwner), new(models.History), + new(models.PlaylistSongVoter), ) if err != nil { return err diff --git a/cmd/server/server.go b/cmd/server/server.go index a8dd1e7..d7a0d2c 100644 --- a/cmd/server/server.go +++ b/cmd/server/server.go @@ -42,10 +42,11 @@ func StartServer(staticFS embed.FS) error { playlistOwnersRepo := db.NewBaseDB[models.PlaylistOwner](dbConn) playlistSongsRepo := db.NewBaseDB[models.PlaylistSong](dbConn) historyRepo := db.NewBaseDB[models.History](dbConn) + playlistVotersRepo := db.NewBaseDB[models.PlaylistSongVoter](dbConn) downloadService := download.New(songRepo) playlistsService := playlists.New(playlistRepo, playlistOwnersRepo, playlistSongsRepo) - songsService := songs.New(playlistSongsRepo, playlistOwnersRepo, songRepo, playlistRepo, downloadService) + songsService := songs.New(playlistSongsRepo, playlistOwnersRepo, songRepo, playlistRepo, playlistVotersRepo, downloadService) historyService := history.New(historyRepo, songRepo) jwtUtil := jwt.NewJWTImpl() @@ -85,7 +86,7 @@ func StartServer(staticFS embed.FS) error { emailLoginApi := apis.NewEmailLoginApi(login.NewEmailLoginService(accountRepo, profileRepo, otpRepo, jwtUtil)) googleLoginApi := apis.NewGoogleLoginApi(login.NewGoogleLoginService(accountRepo, profileRepo, otpRepo, jwtUtil)) - songDownloadApi := apis.NewDownloadHandler(downloadService, songsService, historyService) + songApi := apis.NewDownloadHandler(downloadService, songsService, historyService) playlistsApi := apis.NewPlaylistApi(playlistsService, songsService) historyApi := apis.NewHistoryApi(historyService) @@ -98,13 +99,16 @@ func StartServer(staticFS embed.FS) error { apisHandler.HandleFunc("/login/google/callback", googleLoginApi.HandleGoogleOAuthLoginCallback) apisHandler.HandleFunc("GET /logout", apis.HandleLogout) apisHandler.HandleFunc("GET /search-suggestion", apis.HandleSearchSuggestions) - apisHandler.HandleFunc("GET /song", gHandler.OptionalAuthApi(songDownloadApi.HandlePlaySong)) + apisHandler.HandleFunc("GET /song", gHandler.OptionalAuthApi(songApi.HandlePlaySong)) + apisHandler.HandleFunc("PUT /song/playlist", gHandler.AuthApi(playlistsApi.HandleToggleSongInPlaylist)) + apisHandler.HandleFunc("PUT /song/playlist/plays", gHandler.AuthApi(songApi.HandleIncrementSongPlaysInPlaylist)) + apisHandler.HandleFunc("PUT /song/playlist/upvote", gHandler.AuthApi(songApi.HandleUpvoteSongPlaysInPlaylist)) + apisHandler.HandleFunc("PUT /song/playlist/downvote", gHandler.AuthApi(songApi.HandleDownvoteSongPlaysInPlaylist)) + apisHandler.HandleFunc("GET /playlist/all", gHandler.AuthApi(playlistsApi.HandleGetPlaylistsForPopover)) apisHandler.HandleFunc("POST /playlist", gHandler.AuthApi(playlistsApi.HandleCreatePlaylist)) apisHandler.HandleFunc("PUT /playlist/public", gHandler.AuthApi(playlistsApi.HandleTogglePublicPlaylist)) apisHandler.HandleFunc("PUT /playlist/join", gHandler.AuthApi(playlistsApi.HandleToggleJoinPlaylist)) apisHandler.HandleFunc("DELETE /playlist", gHandler.AuthApi(playlistsApi.HandleDeletePlaylist)) - apisHandler.HandleFunc("PUT /toggle-song-in-playlist", gHandler.AuthApi(playlistsApi.HandleToggleSongInPlaylist)) - apisHandler.HandleFunc("PUT /increment-song-plays", gHandler.AuthApi(songDownloadApi.HandleIncrementSongPlaysInPlaylist)) apisHandler.HandleFunc("GET /history/{page}", gHandler.AuthApi(historyApi.HandleGetMoreHistoryItems)) applicationHandler := http.NewServeMux() diff --git a/db/allowed_models.go b/db/allowed_models.go index 6f47782..b6c50f4 100644 --- a/db/allowed_models.go +++ b/db/allowed_models.go @@ -5,6 +5,6 @@ import "dankmuzikk/models" type AllowedModels interface { models.Account | models.Profile | models.EmailVerificationCode | models.Song | models.Playlist | models.PlaylistSong | models.PlaylistOwner | - models.History + models.History | models.PlaylistSongVoter GetId() uint } diff --git a/entities/song.go b/entities/song.go index c4e93d5..fdb0739 100644 --- a/entities/song.go +++ b/entities/song.go @@ -7,5 +7,6 @@ type Song struct { ThumbnailUrl string `json:"thumbnail_url"` Duration string `json:"duration"` PlayTimes int `json:"play_times"` + Votes int `json:"votes"` AddedAt string `json:"added_at"` } diff --git a/handlers/apis/history.go b/handlers/apis/history.go index 5daddca..34d9bdf 100644 --- a/handlers/apis/history.go +++ b/handlers/apis/history.go @@ -37,6 +37,10 @@ func (h *historyApi) HandleGetMoreHistoryItems(w http.ResponseWriter, r *http.Re if err != nil { log.Errorln(err) } + if len(recentPlays) == 0 { + w.WriteHeader(http.StatusNotFound) + return + } outBuf := bytes.NewBuffer([]byte{}) for _, s := range recentPlays { diff --git a/handlers/apis/playlist.go b/handlers/apis/playlist.go index 525b642..ba9167b 100644 --- a/handlers/apis/playlist.go +++ b/handlers/apis/playlist.go @@ -7,6 +7,7 @@ import ( "dankmuzikk/log" "dankmuzikk/services/playlists" "dankmuzikk/services/playlists/songs" + "dankmuzikk/views/components/playlist" "dankmuzikk/views/pages" "encoding/json" "net/http" @@ -78,7 +79,12 @@ func (p *playlistApi) HandleToggleSongInPlaylist(w http.ResponseWriter, r *http. } if added { - _, _ = w.Write([]byte("
")) + _, _ = w.Write([]byte(`
+ + + + +
`)) } else { _, _ = w.Write([]byte("
")) } @@ -160,3 +166,27 @@ func (p *playlistApi) HandleDeletePlaylist(w http.ResponseWriter, r *http.Reques w.Header().Set("HX-Redirect", "/playlists") } + +func (p *playlistApi) HandleGetPlaylistsForPopover(w http.ResponseWriter, r *http.Request) { + profileId, profileIdCorrect := r.Context().Value(handlers.ProfileIdKey).(uint) + if !profileIdCorrect { + w.Write([]byte("🤷‍♂️")) + return + } + + songId := r.URL.Query().Get("song-id") + if songId == "" { + w.WriteHeader(http.StatusBadRequest) + return + } + + playlists, songsInPlaylists, err := p.service.GetAllMappedForAddPopover(profileId) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + log.Errorln(err) + return + } + + playlist.PlaylistsSelector(songId, playlists, songsInPlaylists). + Render(r.Context(), w) +} diff --git a/handlers/apis/songs.go b/handlers/apis/songs.go index 0fcd6db..5d68448 100644 --- a/handlers/apis/songs.go +++ b/handlers/apis/songs.go @@ -6,6 +6,7 @@ import ( "dankmuzikk/services/history" "dankmuzikk/services/playlists/songs" "dankmuzikk/services/youtube/download" + "fmt" "net/http" ) @@ -52,6 +53,60 @@ func (s *songDownloadHandler) HandleIncrementSongPlaysInPlaylist(w http.Response } } +func (s *songDownloadHandler) HandleUpvoteSongPlaysInPlaylist(w http.ResponseWriter, r *http.Request) { + profileId, profileIdCorrect := r.Context().Value(handlers.ProfileIdKey).(uint) + if !profileIdCorrect { + w.Write([]byte("🤷‍♂️")) + return + } + songId := r.URL.Query().Get("song-id") + if songId == "" { + w.WriteHeader(http.StatusBadRequest) + return + } + playlistId := r.URL.Query().Get("playlist-id") + if playlistId == "" { + w.WriteHeader(http.StatusBadRequest) + return + } + + votes, err := s.songsService.UpvoteSong(songId, playlistId, profileId) + if err != nil { + log.Errorln(err) + w.WriteHeader(http.StatusInternalServerError) + return + } + + _, _ = w.Write([]byte(fmt.Sprint(votes))) +} + +func (s *songDownloadHandler) HandleDownvoteSongPlaysInPlaylist(w http.ResponseWriter, r *http.Request) { + profileId, profileIdCorrect := r.Context().Value(handlers.ProfileIdKey).(uint) + if !profileIdCorrect { + w.Write([]byte("🤷‍♂️")) + return + } + songId := r.URL.Query().Get("song-id") + if songId == "" { + w.WriteHeader(http.StatusBadRequest) + return + } + playlistId := r.URL.Query().Get("playlist-id") + if playlistId == "" { + w.WriteHeader(http.StatusBadRequest) + return + } + + votes, err := s.songsService.DownvoteSong(songId, playlistId, profileId) + if err != nil { + log.Errorln(err) + w.WriteHeader(http.StatusInternalServerError) + return + } + + _, _ = w.Write([]byte(fmt.Sprint(votes))) +} + func (s *songDownloadHandler) HandlePlaySong(w http.ResponseWriter, r *http.Request) { id := r.URL.Query().Get("id") if id == "" { diff --git a/handlers/pages/pages.go b/handlers/pages/pages.go index f080a98..2d7cbf5 100644 --- a/handlers/pages/pages.go +++ b/handlers/pages/pages.go @@ -189,20 +189,14 @@ func (p *pagesHandler) HandleSearchResultsPage(w http.ResponseWriter, r *http.Re log.Info("downloading songs' meta data from search") _ = p.downloadService.DownloadYoutubeSongsMetadata(results) } - var songsInPlaylists map[string]bool - var playlists []entities.Playlist - profileId, profileIdCorrect := r.Context().Value(handlers.ProfileIdKey).(uint) - if profileIdCorrect { - playlists, songsInPlaylists, _ = p.playlistsService.GetAllMappedForAddPopover(profileId) - } if handlers.IsNoLayoutPage(r) { w.Header().Set("HX-Title", "Results for "+query) w.Header().Set("HX-Push-Url", "/search?query="+query) - pages.SearchResults(results, playlists, songsInPlaylists).Render(r.Context(), w) + pages.SearchResults(results).Render(r.Context(), w) return } - layouts.Default("Results for "+query, pages.SearchResults(results, playlists, songsInPlaylists)).Render(r.Context(), w) + layouts.Default("Results for "+query, pages.SearchResults(results)).Render(r.Context(), w) } func (p *pagesHandler) HandleSignupPage(w http.ResponseWriter, r *http.Request) { diff --git a/models/playlist.go b/models/playlist.go index 6456e16..3cb155a 100644 --- a/models/playlist.go +++ b/models/playlist.go @@ -86,11 +86,18 @@ func (p *PlaylistSong) BeforeDelete(tx *gorm.DB) error { return err } - return tx. + err = tx. Model(&playlist). Where("id = ?", p.PlaylistId). Update("songs_count", playlist.SongsCount-1). Error + if err != nil { + return err + } + + return tx. + Exec("DELETE FROM playlist_song_voters WHERE playlist_id = ? AND song_id = ?", p.PlaylistId, p.SongId). + Error } type PlaylistOwner struct { @@ -114,3 +121,18 @@ const ( OwnerPermission NonePermission PlaylistPermissions = 0 ) + +// PlaylistSongVoter ensures that an account had voted only once. +type PlaylistSongVoter struct { + PlaylistId uint `gorm:"primaryKey"` + SongId uint `gorm:"primaryKey"` + ProfileId uint `gorm:"primaryKey"` + VoteUp bool + + CreatedAt time.Time + UpdatedAt time.Time +} + +func (p PlaylistSongVoter) GetId() uint { + return p.SongId | p.PlaylistId | p.ProfileId +} diff --git a/services/playlists/errors.go b/services/playlists/errors.go index d6689bb..017f886 100644 --- a/services/playlists/errors.go +++ b/services/playlists/errors.go @@ -7,4 +7,5 @@ var ( ErrNonOwnerCantDeletePlaylists = errors.New("playlists: non owners can only leave playlists") ErrUnauthorizedToSeePlaylist = errors.New("playlists: unauthorized to see playlist") ErrEmptyPlaylist = errors.New("playlists: empty playlists") + ErrUserHasAlreadyVoted = errors.New("playlist: user can't vote more than once") ) diff --git a/services/playlists/playlists.go b/services/playlists/playlists.go index dba03ae..3156ca2 100644 --- a/services/playlists/playlists.go +++ b/services/playlists/playlists.go @@ -131,7 +131,7 @@ func (p *Service) Get(playlistPubId string, ownerId uint) (playlist entities.Pla return entities.Playlist{}, models.NonePermission, ErrUnauthorizedToSeePlaylist } - gigaQuery := `SELECT yt_id, title, artist, thumbnail_url, duration, ps.created_at, ps.play_times + gigaQuery := `SELECT yt_id, title, artist, thumbnail_url, duration, ps.created_at, ps.play_times, ps.votes FROM playlist_songs ps JOIN songs @@ -152,7 +152,7 @@ func (p *Service) Get(playlistPubId string, ownerId uint) (playlist entities.Pla for rows.Next() { var song entities.Song var addedAt time.Time - err = rows.Scan(&song.YtId, &song.Title, &song.Artist, &song.ThumbnailUrl, &song.Duration, &addedAt, &song.PlayTimes) + err = rows.Scan(&song.YtId, &song.Title, &song.Artist, &song.ThumbnailUrl, &song.Duration, &addedAt, &song.PlayTimes, &song.Votes) if err != nil { continue } diff --git a/services/playlists/songs/songs.go b/services/playlists/songs/songs.go index d7eb238..e95c08a 100644 --- a/services/playlists/songs/songs.go +++ b/services/playlists/songs/songs.go @@ -2,7 +2,9 @@ package songs import ( "dankmuzikk/db" + "dankmuzikk/log" "dankmuzikk/models" + "dankmuzikk/services/playlists" "dankmuzikk/services/youtube/download" "errors" ) @@ -10,11 +12,12 @@ import ( // Service represents songs in platlists management service, // where it adds and deletes songs to and from playlists type Service struct { - playlistSongRepo db.UnsafeCRUDRepo[models.PlaylistSong] - playlistOwnerRepo db.CRUDRepo[models.PlaylistOwner] - songRepo db.UnsafeCRUDRepo[models.Song] - playlistRepo db.UnsafeCRUDRepo[models.Playlist] - downloadService *download.Service + playlistSongRepo db.UnsafeCRUDRepo[models.PlaylistSong] + playlistOwnerRepo db.CRUDRepo[models.PlaylistOwner] + songRepo db.UnsafeCRUDRepo[models.Song] + playlistRepo db.UnsafeCRUDRepo[models.Playlist] + playlistVotersRepo db.CRUDRepo[models.PlaylistSongVoter] + downloadService *download.Service } // New accepts repos lol, and returns a new instance to the songs playlists service. @@ -23,14 +26,16 @@ func New( playlistOwnerRepo db.CRUDRepo[models.PlaylistOwner], songRepo db.UnsafeCRUDRepo[models.Song], playlistRepo db.UnsafeCRUDRepo[models.Playlist], + playlistVotersRepo db.CRUDRepo[models.PlaylistSongVoter], downloadService *download.Service, ) *Service { return &Service{ - playlistSongRepo: playlistSongRepo, - playlistOwnerRepo: playlistOwnerRepo, - songRepo: songRepo, - playlistRepo: playlistRepo, - downloadService: downloadService, + playlistSongRepo: playlistSongRepo, + playlistOwnerRepo: playlistOwnerRepo, + songRepo: songRepo, + playlistRepo: playlistRepo, + playlistVotersRepo: playlistVotersRepo, + downloadService: downloadService, } } @@ -55,6 +60,7 @@ func (s *Service) ToggleSongInPlaylist(songId, playlistPubId string, ownerId uin err = s.playlistSongRepo.Add(&models.PlaylistSong{ PlaylistId: playlist[0].Id, SongId: song[0].Id, + Votes: 1, }) if err != nil { return @@ -70,55 +76,203 @@ func (s *Service) ToggleSongInPlaylist(songId, playlistPubId string, ownerId uin // IncrementSongPlays increases the song's play times in the given playlist. // Checks for the song and playlist first, yada yada... func (s *Service) IncrementSongPlays(songId, playlistPubId string, ownerId uint) error { - var playlist models.Playlist - err := s. - songRepo. + gigaQuery := `SELECT pl.id, s.id + FROM + playlist_owners po + JOIN playlists pl + ON po.playlist_id = pl.id + JOIN playlist_songs ps + ON ps.playlist_id = pl.id + JOIN songs s + ON ps.song_id = ps.song_id + WHERE + pl.public_id = ? + AND + s.yt_id = ? + AND + po.profile_id = ? + LIMIT 1;` + + var songDbId, playlistDbId uint + err := s.songRepo. GetDB(). - Model(new(models.Playlist)). - Select("id"). - Where("public_id = ?", playlistPubId). - First(&playlist). - Error + Raw(gigaQuery, playlistPubId, songId, ownerId). + Row(). + Scan(&playlistDbId, &songDbId) if err != nil { return err } - _, err = s.playlistOwnerRepo.GetByConds("profile_id = ? AND playlist_id = ?", ownerId, playlist.Id) + updateQuery := `UPDATE playlist_songs + SET play_times = play_times + 1 + WHERE + playlist_id = ? AND song_id = ?;` + + err = s.songRepo. + GetDB(). + Exec(updateQuery, playlistDbId, songDbId). + Error if err != nil { return err } - var song models.Song - err = s. - songRepo. + return nil +} + +// UpvoteSong increases the song's votes in the given playlist. +// Checks for the song and playlist first, yada yada... +func (s *Service) UpvoteSong(songId, playlistPubId string, ownerId uint) (int, error) { + gigaQuery := `SELECT pl.id, s.id + FROM + playlist_owners po + JOIN playlists pl + ON po.playlist_id = pl.id + JOIN playlist_songs ps + ON ps.playlist_id = pl.id + JOIN songs s + ON ps.song_id = ps.song_id + WHERE + pl.public_id = ? + AND + s.yt_id = ? + AND + po.profile_id = ? + LIMIT 1;` + + var songDbId, playlistDbId uint + err := s.songRepo. GetDB(). - Model(new(models.Song)). - Select("id"). - Where("yt_id = ?", songId). - First(&song). - Error + Raw(gigaQuery, playlistPubId, songId, ownerId). + Row(). + Scan(&playlistDbId, &songDbId) if err != nil { - return err + return 0, err + } + + voter, err := s. + playlistVotersRepo. + GetByConds("playlist_id = ? AND song_id = ? AND profile_id = ? AND vote_up = 1", + playlistDbId, + songDbId, + ownerId, + ) + if err == nil && len(voter) != 0 { + return 0, playlists.ErrUserHasAlreadyVoted } - var ps models.PlaylistSong - err = s. - playlistSongRepo. + updateQuery := `UPDATE playlist_songs + SET votes = votes + 1 + WHERE + playlist_id = ? AND song_id = ?;` + + err = s.songRepo. GetDB(). - Model(new(models.PlaylistSong)). - Select("play_times"). - Where("playlist_id = ? AND song_id = ?", playlist.Id, song.Id). - First(&ps). + Exec(updateQuery, playlistDbId, songDbId). Error if err != nil { - return err + return 0, err } - return s. - playlistSongRepo. + ps, err := s.playlistSongRepo.GetByConds("playlist_id = ? AND song_id = ?", playlistDbId, songDbId) + if err != nil { + return 0, err + } + + err = s.playlistVotersRepo.Add(&models.PlaylistSongVoter{ + PlaylistId: playlistDbId, + SongId: songDbId, + ProfileId: ownerId, + VoteUp: true, + }) + log.Warningf("%+v\n%v\n", ps, err) + if errors.Is(err, db.ErrRecordExists) { + return ps[0].Votes, s. + songRepo. + GetDB(). + Exec("UPDATE playlist_song_voters SET vote_up = 1 WHERE playlist_id = ? AND song_id = ? AND profile_id = ?", playlistDbId, songDbId, ownerId). + Error + } else { + return ps[0].Votes, err + } +} + +// DownvoteSong decreases the song's votes in the given playlist. +// Checks for the song and playlist first, yada yada... +func (s *Service) DownvoteSong(songId, playlistPubId string, ownerId uint) (int, error) { + gigaQuery := `SELECT pl.id, s.id + FROM + playlist_owners po + JOIN playlists pl + ON po.playlist_id = pl.id + JOIN playlist_songs ps + ON ps.playlist_id = pl.id + JOIN songs s + ON ps.song_id = ps.song_id + WHERE + pl.public_id = ? + AND + s.yt_id = ? + AND + po.profile_id = ? + LIMIT 1;` + + var songDbId, playlistDbId uint + err := s.songRepo. + GetDB(). + Raw(gigaQuery, playlistPubId, songId, ownerId). + Row(). + Scan(&playlistDbId, &songDbId) + if err != nil { + return 0, err + } + + voter, err := s. + playlistVotersRepo. + GetByConds("playlist_id = ? AND song_id = ? AND profile_id = ? AND vote_up = 0", + playlistDbId, + songDbId, + ownerId, + ) + if err == nil && len(voter) != 0 { + return 0, playlists.ErrUserHasAlreadyVoted + } + log.Warningf("suka: %+v\n", voter) + + updateQuery := `UPDATE playlist_songs + SET votes = votes - 1 + WHERE + playlist_id = ? AND song_id = ?;` + err = s.songRepo. GetDB(). - Model(new(models.PlaylistSong)). - Where("playlist_id = ? AND song_id = ?", playlist.Id, song.Id). - Update("play_times", ps.PlayTimes+1). + Exec(updateQuery, playlistDbId, songDbId). Error + if err != nil { + return 0, err + } + + // remove song from playlist if votes < 0 + ps, err := s.playlistSongRepo.GetByConds("playlist_id = ? AND song_id = ?", playlistDbId, songDbId) + if err != nil { + return 0, err + } + if ps[0].Votes < 0 { + return 0, s.playlistSongRepo.Delete("playlist_id = ? AND song_id = ?", playlistDbId, songDbId) + } + + err = s.playlistVotersRepo.Add(&models.PlaylistSongVoter{ + PlaylistId: playlistDbId, + SongId: songDbId, + ProfileId: ownerId, + VoteUp: false, + }) + log.Warningf("%+v\n%v\n", ps, err) + if errors.Is(err, db.ErrRecordExists) { + return ps[0].Votes, s. + songRepo. + GetDB(). + Exec("UPDATE playlist_song_voters SET vote_up = 0 WHERE playlist_id = ? AND song_id = ? AND profile_id = ?", playlistDbId, songDbId, ownerId). + Error + } else { + return ps[0].Votes, err + } } diff --git a/static/css/style.css b/static/css/style.css index 7c4a536..ce70c0f 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -13,18 +13,23 @@ table { * { --animation-duration: 0.4s; -webkit-transition: + all var(--animation-duration), background-color var(--animation-duration), opacity var(--animation-duration); -moz-transition: + all var(--animation-duration), background-color var(--animation-duration), opacity var(--animation-duration); -o-transition: + all var(--animation-duration), background-color var(--animation-duration), opacity var(--animation-duration); -ms-transition: + all var(--animation-duration), background-color var(--animation-duration), opacity var(--animation-duration); transition: + all var(--animation-duration), background-color var(--animation-duration), opacity var(--animation-duration); } diff --git a/static/js/player.js b/static/js/player.js index 3fcc67c..5f7e68c 100644 --- a/static/js/player.js +++ b/static/js/player.js @@ -40,6 +40,7 @@ const playPauseToggleExapndedEl = document.getElementById("play-expand"), * @property {string} yt_id * @property {number} play_times * @property {string} added_at + * @property {number} votes */ /** @@ -213,7 +214,7 @@ function stopper(audioEl) { "song-" + playerState.playlist.songs[playerState.currentSongIdx].yt_id, ); if (!!songEl) { - songEl.style.backgroundColor = "var(--secondary-color-20)"; + songEl.style.backgroundColor = "#ffffff00"; } setPlayerButtonIcon(playPauseToggleEl, Player.icons.play); setPlayerButtonIcon(playPauseToggleExapndedEl, Player.icons.play); @@ -260,36 +261,44 @@ function playlister(state) { } }; - const __updateSongPlays = async () => { - if (!state.playlist.public_id) { - return; - } - await fetch( - "/api/increment-song-plays?" + - new URLSearchParams({ - "song-id": state.playlist.songs[state.currentSongIdx].yt_id, - "playlist-id": state.playlist.public_id, - }).toString(), - { - method: "PUT", - }, - ).catch((err) => console.error(err)); - }; - - const __next = () => { + const __next = async () => { + console.log( + "__next", + state.playlist.songs.map((s) => { + return { title: s.title, plays: s.plays, votes: s.votes }; + }), + ); if (checkLoop(LOOP_MODES.ONCE)) { stopMuzikk(); playMuzikk(); + await updateSongPlays(); + return; + } + // chack votes to whether repeat the song or not. + if (state.playlist.songs[state.currentSongIdx].votes > 1) { + const songToPlay = state.playlist.songs[state.currentSongIdx]; + songToPlay.votes--; + songToPlay.plays++; + playSongFromPlaylist(songToPlay.yt_id, state.playlist); + __setSongInPlaylistStyle(songToPlay.yt_id, state.playlist); return; } + if ( !checkLoop(LOOP_MODES.ALL) && !state.shuffled && state.currentSongIdx + 1 >= state.playlist.songs.length ) { stopMuzikk(); + // reset songs' votes + for (const s of state.playlist.songs) { + if (!!s.plays) { + s.votes = s.plays; + } + } return; } + state.currentSongIdx = state.shuffled ? Math.floor(Math.random() * state.playlist.songs.length) : checkLoop(LOOP_MODES.ALL) && @@ -298,14 +307,23 @@ function playlister(state) { : state.currentSongIdx + 1; const songToPlay = state.playlist.songs[state.currentSongIdx]; playSongFromPlaylist(songToPlay.yt_id, state.playlist); - __updateSongPlays(); __setSongInPlaylistStyle(songToPlay.yt_id, state.playlist); }; - const __prev = () => { + const __prev = async () => { if (checkLoop(LOOP_MODES.ONCE)) { stopMuzikk(); playMuzikk(); + await updateSongPlays(); + return; + } + // chack votes to whether repeat the song or not. + if (state.playlist.songs[state.currentSongIdx].votes > 1) { + const songToPlay = state.playlist.songs[state.currentSongIdx]; + songToPlay.votes--; + songToPlay.plays++; + playSongFromPlaylist(songToPlay.yt_id, state.playlist); + __setSongInPlaylistStyle(songToPlay.yt_id, state.playlist); return; } if ( @@ -314,6 +332,12 @@ function playlister(state) { state.currentSongIdx - 1 < 0 ) { stopMuzikk(); + // reset songs' votes + for (const s of state.playlist.songs) { + if (!!s.plays) { + s.votes = s.plays; + } + } return; } state.currentSongIdx = state.shuffled @@ -323,7 +347,6 @@ function playlister(state) { : state.currentSongIdx - 1; const songToPlay = state.playlist.songs[state.currentSongIdx]; playSongFromPlaylist(songToPlay.yt_id, state.playlist); - __updateSongPlays(); __setSongInPlaylistStyle(songToPlay.yt_id, state.playlist); }; @@ -337,7 +360,7 @@ function playlister(state) { Utils.showLoading(); fetch( - "/api/toggle-song-in-playlist?song-id=" + + "/api/song/playlist?song-id=" + songYtId + "&playlist-id=" + playlistId + @@ -367,6 +390,36 @@ function playlister(state) { return [__next, __prev, __remove, __setSongInPlaylistStyle]; } +function volumer() { + let lastVolume = 1; + const __setVolume = (level) => { + if (level > 1) { + level = 1; + } + if (level < 0) { + level = 0; + } + audioPlayerEl.volume = level; + if (volumeSeekBarEl) { + volumeSeekBarEl.value = Math.floor(level * 100); + } + if (volumeSeekBarExpandedEl) { + volumeSeekBarExpandedEl.value = Math.floor(level * 100); + } + }; + + const __muter = () => { + if (audioPlayerEl.volume === 0) { + __setVolume(lastVolume); + } else { + lastVolume = audioPlayerEl.volume; + __setVolume(0); + } + }; + + return [__setVolume, __muter]; +} + /** * @param {string} songYtId */ @@ -420,11 +473,27 @@ function expand() { function collapse() { if (playerEl.classList.contains("exapnded")) { playerEl.classList.remove("exapnded"); - expandedMobilePlayer.classList.add("hidden"); collapsedMobilePlayer.classList.remove("hidden"); + expandedMobilePlayer.classList.add("hidden"); } } +async function updateSongPlays() { + if (!playerState.playlist.public_id) { + return; + } + await fetch( + "/api/song/playlist/plays?" + + new URLSearchParams({ + "song-id": playerState.playlist.songs[playerState.currentSongIdx].yt_id, + "playlist-id": playerState.playlist.public_id, + }).toString(), + { + method: "PUT", + }, + ).catch((err) => console.error(err)); +} + /** * @param {Song} song */ @@ -480,6 +549,7 @@ async function playSong(song) { } setMediaSessionMetadata(song); playMuzikk(); + await updateSongPlays(); } /** @@ -507,7 +577,14 @@ function playSongFromPlaylist(songYtId, playlist) { return; } playerState.playlist = playlist; + playerState.playlist.songs = playlist.songs.map((s) => { + return { ...s, plays: 0 }; + }); playerState.currentSongIdx = songIdx; + console.log("song index", songIdx); + if (playerState.playlist.songs[songIdx].votes === 0) { + playerState.currentSongIdx++; + } const songToPlay = playlist.songs[songIdx]; highlightSongInPlaylist(songToPlay.yt_id, playlist); playSong(songToPlay); @@ -571,6 +648,7 @@ const [ removeSongFromPlaylist, highlightSongInPlaylist, ] = playlister(playerState); +const [setVolume, mute] = volumer(); playPauseToggleEl.addEventListener("click", (event) => { event.stopImmediatePropagation(); diff --git a/static/js/player_shortcuts.js b/static/js/player_shortcuts.js new file mode 100644 index 0000000..f983e5b --- /dev/null +++ b/static/js/player_shortcuts.js @@ -0,0 +1,67 @@ +"use strict"; + +/** + * Using YouTube's applicaple shortcuts: https://support.google.com/youtube/answer/7631406?hl=en + */ +const shortcuts = { + " ": togglePP, + k: togglePP, + n: nextMuzikk, + N: nextMuzikk, + p: previousMuzikk, + P: previousMuzikk, + s: stopMuzikk, + m: mute, + M: mute, + 0: () => (audioPlayerEl.currentTime = 0), + 1: () => (audioPlayerEl.currentTime = audioPlayerEl.duration * 0.1), + 2: () => (audioPlayerEl.currentTime = audioPlayerEl.duration * 0.2), + 3: () => (audioPlayerEl.currentTime = audioPlayerEl.duration * 0.3), + 4: () => (audioPlayerEl.currentTime = audioPlayerEl.duration * 0.4), + 5: () => (audioPlayerEl.currentTime = audioPlayerEl.duration * 0.5), + 6: () => (audioPlayerEl.currentTime = audioPlayerEl.duration * 0.6), + 7: () => (audioPlayerEl.currentTime = audioPlayerEl.duration * 0.7), + 8: () => (audioPlayerEl.currentTime = audioPlayerEl.duration * 0.8), + 9: () => (audioPlayerEl.currentTime = audioPlayerEl.duration * 0.9), + $: () => (audioPlayerEl.currentTime = audioPlayerEl.duration), + ArrowLeft: () => (audioPlayerEl.currentTime -= 10), + ArrowRight: () => (audioPlayerEl.currentTime += 10), + ArrowUp: () => setVolume(audioPlayerEl.volume + 0.1), + ArrowDown: () => setVolume(audioPlayerEl.volume - 0.1), + i: expand, + I: collapse, + "/": () => searchInputEl.focus(), +}; + +/** + * @param {KeyboardEvent} e + */ +document.addEventListener("keyup", (e) => { + if ( + [searchFormEl, searchInputEl, searchSugEl].includes(document.activeElement) + ) { + return; + } + const action = shortcuts[e.key]; + if (action) { + e.stopImmediatePropagation(); + e.preventDefault(); + action(); + } +}); + +/** + * @param {KeyboardEvent} e + */ +document.addEventListener("keydown", (e) => { + if ( + [searchFormEl, searchInputEl, searchSugEl].includes(document.activeElement) + ) { + return; + } + const action = shortcuts[e.key]; + if (action) { + e.stopImmediatePropagation(); + e.preventDefault(); + } +}); diff --git a/views/components/header/header.templ b/views/components/header/header.templ index 2367e8a..fe46773 100644 --- a/views/components/header/header.templ +++ b/views/components/header/header.templ @@ -23,7 +23,7 @@ templ homeLinkContainer() { templ mobileHeader() {
- @navlink.LinkContainer("/about", "About", icons.About()) + @navlink.ImageRouteLink("/about", "About", icons.About())
  • - @navlink.NavLink("About", "", "/about") + @navlink.RouteLink("About", "", "/about")
  • - @navlink.NavLink("Playlists", "", "/playlists") + @navlink.RouteLink("Playlists", "", "/playlists")
  • - @navlink.NavLink("Profile", "", "/profile") + @navlink.RouteLink("Profile", "", "/profile")
diff --git a/views/components/mobilenav/mobilenav.templ b/views/components/mobilenav/mobilenav.templ index 768ddce..3802575 100644 --- a/views/components/mobilenav/mobilenav.templ +++ b/views/components/mobilenav/mobilenav.templ @@ -12,13 +12,13 @@ templ MobileNav() { >
  • - @navlink.LinkContainer("/", "Home", icons.Home()) + @navlink.ImageRouteLink("/", "Home", icons.Home())
  • - @navlink.LinkContainer("/playlists", "Playlists", icons.Playlist()) + @navlink.ImageRouteLink("/playlists", "Playlists", icons.Playlist())
  • - @navlink.LinkContainer("/profile", "Profile", icons.Profile()) + @navlink.ImageRouteLink("/profile", "Profile", icons.Profile())
diff --git a/views/components/navlink/navlink.templ b/views/components/navlink/navlink.templ index 8ec0893..ac42d82 100644 --- a/views/components/navlink/navlink.templ +++ b/views/components/navlink/navlink.templ @@ -1,6 +1,6 @@ package navlink -templ NavLink(title, imageUrl, path string, showTilte ...bool) { +templ RouteLink(title, imageUrl, path string, showTilte ...bool) { } +templ ImageRouteLink(path, title string, child templ.Component) { + +
+ @child +
+
+} + +templ NavLink(title, imageUrl, path string, showTilte ...bool) { + + if imageUrl != "" { +
+ { + if len(showTilte) > 0 && showTilte[0] { + { title } + } +
+ } else { + { title } + } +
+} + templ LinkContainer(path, title string, child templ.Component) {
@JustLink(path, title, child) @@ -53,7 +107,6 @@ templ JustLink(path, title string, child templ.Component) { data-loading-path={ path + "?no_layout=true" } >
@child diff --git a/views/components/player/mobile_player.templ b/views/components/player/mobile_player.templ index fe95ace..1bb99a0 100644 --- a/views/components/player/mobile_player.templ +++ b/views/components/player/mobile_player.templ @@ -24,7 +24,6 @@ templ mobilePlayer() { .collapsed { height: 90px; max-height: 90px; - transition: max-height .5s; } .exapnded { height: 550px; diff --git a/views/components/player/player.templ b/views/components/player/player.templ index f954ba8..33eeb6d 100644 --- a/views/components/player/player.templ +++ b/views/components/player/player.templ @@ -18,4 +18,5 @@ templ PlayerSticky() { /// + } diff --git a/views/components/playlist/popup.templ b/views/components/playlist/popup.templ index a32175f..16ba33d 100644 --- a/views/components/playlist/popup.templ +++ b/views/components/playlist/popup.templ @@ -7,11 +7,26 @@ import ( "dankmuzikk/views/icons" ) -templ PlaylistsPopup(index int, songId string, playlists []entities.Playlist, songsInPlaylists map[string]bool) { - @popup.Popup(fmt.Sprint(index), "Add to playlist", popoverButton(), playlistsSelector(songId, playlists, songsInPlaylists)) +templ PlaylistsPopup(index int, songId string) { + @popup.Popup(fmt.Sprint(index), "Add to playlist", popupButton(songId), playlistSelector(songId)) } -templ playlistsSelector(songId string, playlists []entities.Playlist, songsInPlaylists map[string]bool) { +templ playlistSelector(songId string) { +
+
+
+
+ Loading playlists... +
+} + +templ PlaylistsSelector(songId string, playlists []entities.Playlist, songsInPlaylists map[string]bool) {
+ > + if songsInPlaylists[songId+"-"+playlist.PublicId] { + + + + + } +

{ playlist.Title } @@ -71,7 +94,15 @@ templ playlistsSelector(songId string, playlists []entities.Playlist, songsInPla

} -templ popoverButton() { - @icons.AddToPlaytlist() - Save to a playlist +templ popupButton(songId string) { +
+ @icons.AddToPlaytlist() + Save to a playlist +
} diff --git a/views/components/popup/popup.templ b/views/components/popup/popup.templ index bf887ac..f5af528 100644 --- a/views/components/popup/popup.templ +++ b/views/components/popup/popup.templ @@ -5,7 +5,7 @@ import "fmt" templ Popup(id, title string, button, child templ.Component) {
-
+
@popover.Popover(s.YtId, "Song options", icons.Options(), options(s, additionalOptions))
diff --git a/views/pages/index.templ b/views/pages/index.templ index 445daae..62baed7 100644 --- a/views/pages/index.templ +++ b/views/pages/index.templ @@ -5,6 +5,7 @@ import ( "dankmuzikk/entities" "dankmuzikk/views/components/song" "dankmuzikk/views/components/page" + "dankmuzikk/views/components/playlist" ) templ Index(recentPlays []entities.Song) { @@ -45,8 +46,8 @@ templ historyContent(recentPlays []entities.Song) { "flex", "flex-col", "gap-5", "lg:mb-5", } > - 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, s.YtId)}, entities.Playlist{}) }
if pl.Songs != nil && len(pl.Songs) > 0 {
} +templ voteSong(songId, playlistId string, votes int) { +

Votes

+
+ + { fmt.Sprint(votes) } + +
+} + templ backButton() {
diff --git a/views/pages/search_results.templ b/views/pages/search_results.templ index f8b5435..3f073bf 100644 --- a/views/pages/search_results.templ +++ b/views/pages/search_results.templ @@ -7,11 +7,11 @@ import ( "dankmuzikk/views/components/page" ) -templ SearchResults(results []entities.Song, playlists []entities.Playlist, songsInPlaylists map[string]bool) { +templ SearchResults(results []entities.Song) { if len(results) == 0 { @page.Container(templ.NopComponent, noResultsHeader()) } else { - @page.Container(searchHeader(), searchContent(results, playlists, songsInPlaylists)) + @page.Container(searchHeader(), searchContent(results)) } } @@ -23,7 +23,7 @@ templ searchHeader() {

Search results

} -templ searchContent(results []entities.Song, playlists []entities.Playlist, songsInPlaylists map[string]bool) { +templ searchContent(results []entities.Song) {
for idx, res := range results { - @song.Song(res, nil, []templ.Component{playlist.PlaylistsPopup(idx, res.YtId, playlists, songsInPlaylists)}, entities.Playlist{}) + @song.Song(res, nil, []templ.Component{playlist.PlaylistsPopup(idx, res.YtId)}, entities.Playlist{}) }
} diff --git a/ytdl/Dockerfile b/ytdl/Dockerfile index 715240a..6acb842 100644 --- a/ytdl/Dockerfile +++ b/ytdl/Dockerfile @@ -4,7 +4,7 @@ ENV PYTHONDONTWRITEBYTECODE=1 ENV PYTHONUNBUFFERED=1 RUN apk update && \ - apk add --no-cache mariadb-dev build-base gcc musl-dev linux-headers + apk add --no-cache mariadb-dev build-base gcc musl-dev linux-headers ffmpeg COPY requirements.txt /app/requirements.txt RUN pip install --upgrade pip && \