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() {