Compare commits
5 Commits
da9f4bb0ec
...
8e226e9ea1
| Author | SHA1 | Date | |
|---|---|---|---|
| 8e226e9ea1 | |||
| 2c84d3d818 | |||
| c7dc944264 | |||
| 286a01c4d6 | |||
| 3d92096bb8 |
40
main.go
40
main.go
@@ -11,6 +11,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path"
|
"path"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
_ "time/tzdata"
|
_ "time/tzdata"
|
||||||
@@ -19,6 +20,24 @@ import (
|
|||||||
_ "github.com/mattn/go-sqlite3"
|
_ "github.com/mattn/go-sqlite3"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type OrderType = string
|
||||||
|
|
||||||
|
const (
|
||||||
|
OrderTypeAsc OrderType = "ASC"
|
||||||
|
OrderTypeDesc OrderType = "DESC"
|
||||||
|
)
|
||||||
|
|
||||||
|
func validateOrderType(orderType string) OrderType {
|
||||||
|
switch strings.ToLower(orderType) {
|
||||||
|
case "asc":
|
||||||
|
return OrderTypeAsc
|
||||||
|
case "desc":
|
||||||
|
return OrderTypeDesc
|
||||||
|
default:
|
||||||
|
return OrderTypeAsc
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type Podcast struct {
|
type Podcast struct {
|
||||||
ID int64 `json:"id" db:"id"`
|
ID int64 `json:"id" db:"id"`
|
||||||
Name string `json:"name" db:"name"`
|
Name string `json:"name" db:"name"`
|
||||||
@@ -198,8 +217,23 @@ func downloadEpisodeAudioFile(url string) ([]byte, error) {
|
|||||||
return data, nil
|
return data, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func getPodcastEpisodes(db *sqlx.DB, podcastId int64) ([]*Episode, error) {
|
func getPodcastEpisodes(db *sqlx.DB, podcastId int64, orderBy string, orderType OrderType) ([]*Episode, error) {
|
||||||
rows, err := db.Queryx("SELECT * FROM episodes WHERE podcast_id = ?", podcastId)
|
allowedColumns := map[string]bool{
|
||||||
|
"title": true,
|
||||||
|
"pubdate": true,
|
||||||
|
"number": true,
|
||||||
|
"created_at": true,
|
||||||
|
}
|
||||||
|
if !allowedColumns[orderBy] {
|
||||||
|
orderBy = "pubdate"
|
||||||
|
}
|
||||||
|
|
||||||
|
orderType = validateOrderType(orderType)
|
||||||
|
|
||||||
|
query := fmt.Sprintf("SELECT * FROM episodes WHERE podcast_id = :podcast_id ORDER BY %s %s", orderBy, orderType)
|
||||||
|
rows, err := db.NamedQuery(query, map[string]any{
|
||||||
|
"podcast_id": podcastId,
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to query db: %v", err)
|
return nil, fmt.Errorf("failed to query db: %v", err)
|
||||||
}
|
}
|
||||||
@@ -425,7 +459,7 @@ func main() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
episodes, err := getPodcastEpisodes(db, int64(id))
|
episodes, err := getPodcastEpisodes(db, int64(id), "pubdate", OrderTypeDesc)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), 500)
|
http.Error(w, err.Error(), 500)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -1,94 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>podcaster</title>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
*,
|
|
||||||
*::before,
|
|
||||||
*::after {
|
|
||||||
box-sizing: border-box;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
max-width: 1440px;
|
|
||||||
width: 100%;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.podcast-form {
|
|
||||||
margin-top: 12px;
|
|
||||||
display: flex;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.podcast-form__input {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.podcast-form__btn {
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.podcasts {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, 250px);
|
|
||||||
gap: 12px;
|
|
||||||
margin-top: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.podcast {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.podcast__cover {
|
|
||||||
width: 100%;
|
|
||||||
aspect-ratio: 1/1;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media screen and (max-width: 1024px) {
|
|
||||||
.podcast-form {
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.podcast-form__btn {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media screen and (max-width: 528px) {
|
|
||||||
.podcasts {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<h1>podcaster</h1>
|
|
||||||
|
|
||||||
<form method="POST" action="/podcasts" class="podcast-form">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
inputmode="url"
|
|
||||||
placeholder="rss feed"
|
|
||||||
name="feed"
|
|
||||||
class="podcast-form__input"
|
|
||||||
/>
|
|
||||||
<button type="submit" class="podcast-form__btn">Add podcast</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div class="podcasts">
|
|
||||||
{{range .Podcasts}}
|
|
||||||
<a href="/podcasts/{{.ID}}" class="podcast">
|
|
||||||
<img src="{{.Image}}" alt="" class="podcast__cover" />
|
|
||||||
<span>{{.Name}}</span>
|
|
||||||
</a>
|
|
||||||
{{end}}
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>{{.Podcast.Name}}</title>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
*,
|
|
||||||
*::before,
|
|
||||||
*::after {
|
|
||||||
box-sizing: border-box;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
max-width: 1440px;
|
|
||||||
width: 100%;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header {
|
|
||||||
margin-top: 12px;
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header img {
|
|
||||||
width: 300px;
|
|
||||||
aspect-ration: 1/1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header .info {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.episodes {
|
|
||||||
margin-top: 24px;
|
|
||||||
padding: 10px;
|
|
||||||
border-top: 1px solid;
|
|
||||||
}
|
|
||||||
|
|
||||||
.episode {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<h1><a href="/">podcaster</a></h1>
|
|
||||||
|
|
||||||
<div class="header">
|
|
||||||
<img src="{{.Podcast.Image}}" alt="" />
|
|
||||||
<div class="info">
|
|
||||||
<h2>{{.Podcast.Name}}</h2>
|
|
||||||
<p>{{.Podcast.Description}}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="episodes">
|
|
||||||
{{range .Episodes}}
|
|
||||||
<div class="episode">
|
|
||||||
<span>{{.Title}}</span>
|
|
||||||
<span>Added {{.CreatedAt.Format "Mon, Jan 2, 2006 15:04"}}</span>
|
|
||||||
</div>
|
|
||||||
{{end}}
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -52,7 +52,7 @@ export const PodcastPage = () => {
|
|||||||
)}
|
)}
|
||||||
<span>{episode.title}</span>
|
<span>{episode.title}</span>
|
||||||
</div>
|
</div>
|
||||||
<span>Added {new Date(episode.createdAt).toLocaleString()}</span>
|
<span>{new Date(episode.createdAt).toLocaleString()}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -68,61 +68,78 @@ export const Player = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed bottom-0 left-0 right-0 z-50 max-w-[1440px] w-full mx-auto">
|
<div className="fixed bottom-0 left-0 right-0 z-50">
|
||||||
<div className="bg-white py-2 px-4 flex items-center gap-4">
|
<div className="max-w-[1440px] w-full mx-auto">
|
||||||
<div className="flex items-center gap-2">
|
<div className="bg-white py-2 px-4 flex flex-col">
|
||||||
{status === "playing" ? (
|
<div className="w-full flex items-center gap-4">
|
||||||
<button onClick={() => setStatus("paused")}>
|
<div className="flex items-center gap-2">
|
||||||
<PauseIcon size={24} />
|
{status === "playing" ? (
|
||||||
</button>
|
<button onClick={() => setStatus("paused")}>
|
||||||
) : (
|
<PauseIcon size={24} />
|
||||||
<button onClick={() => setStatus("playing")}>
|
</button>
|
||||||
<PlayIcon size={24} />
|
) : (
|
||||||
</button>
|
<button onClick={() => setStatus("playing")}>
|
||||||
)}
|
<PlayIcon size={24} />
|
||||||
<button
|
</button>
|
||||||
onClick={() => {
|
)}
|
||||||
if (!audioRef.current) return;
|
<button
|
||||||
audioRef.current.currentTime -= 10;
|
onClick={() => {
|
||||||
}}
|
if (!audioRef.current) return;
|
||||||
>
|
audioRef.current.currentTime -= 10;
|
||||||
<RewindIcon size={24} />
|
}}
|
||||||
</button>
|
>
|
||||||
<button
|
<RewindIcon size={24} />
|
||||||
onClick={() => {
|
</button>
|
||||||
if (!audioRef.current) return;
|
<button
|
||||||
audioRef.current.currentTime += 10;
|
onClick={() => {
|
||||||
}}
|
if (!audioRef.current) return;
|
||||||
>
|
audioRef.current.currentTime += 10;
|
||||||
<FastForwardIcon size={24} />
|
}}
|
||||||
</button>
|
>
|
||||||
</div>
|
<FastForwardIcon size={24} />
|
||||||
<div
|
</button>
|
||||||
className="w-full h-2 flex items-center rounded-lg bg-neutral-200 overflow-hidden hover:h-5 transition-[height] ease-linear"
|
</div>
|
||||||
onClick={(e) => {
|
<div
|
||||||
if (!audioRef.current) return;
|
className="w-full h-2 flex items-center rounded-lg bg-neutral-200 overflow-hidden hover:h-5 transition-[height] ease-linear"
|
||||||
|
onClick={(e) => {
|
||||||
|
if (!audioRef.current) return;
|
||||||
|
|
||||||
const rect = e.currentTarget.getBoundingClientRect();
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
const left = e.clientX - rect.x;
|
const left = e.clientX - rect.x;
|
||||||
const percent = Math.floor((left / rect.width) * 100);
|
const percent = Math.floor((left / rect.width) * 100);
|
||||||
const newTime = (duration * percent) / 100;
|
const newTime = (duration * percent) / 100;
|
||||||
audioRef.current.currentTime = newTime;
|
audioRef.current.currentTime = newTime;
|
||||||
setCurrentTime(newTime);
|
setCurrentTime(newTime);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
style={{ width: `${progress * 100}%` }}
|
style={{ width: `${progress * 100}%` }}
|
||||||
className="bg-red-500 h-full"
|
className="bg-red-500 h-full"
|
||||||
></div>
|
></div>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
className={`${Math.floor(duration / 3600) > 0 ? "min-w-[70px]" : "min-w-[50px]"} text-right`}
|
||||||
|
>
|
||||||
|
{formatTime(currentTime)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 className="text-base font-semibold mt-2">{episode?.title}</h3>
|
||||||
|
<p className="opacity-50 mt-1">{formatTime(duration)}</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="min-w-[50px] text-right">{formatTime(currentTime)}</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatTime = (seconds: number): string => {
|
const formatTime = (time: number): string => {
|
||||||
const m = Math.floor(seconds / 60);
|
let hours = Math.floor(time / 3600);
|
||||||
const s = Math.floor(seconds % 60);
|
let minutes = Math.floor((time % 3600) / 60);
|
||||||
return `${m}:${String(s).padStart(2, "0")}`;
|
let seconds = Math.floor(time % 60);
|
||||||
|
|
||||||
|
const h = String(hours).padStart(2, "0");
|
||||||
|
const m = String(minutes).padStart(2, "0");
|
||||||
|
const s = String(seconds).padStart(2, "0");
|
||||||
|
|
||||||
|
return [h, m, s].join(":");
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user