Initial commit
This commit is contained in:
commit
e0e446cca0
|
@ -0,0 +1,2 @@
|
|||
/.cache/
|
||||
/feed.html
|
|
@ -0,0 +1,3 @@
|
|||
https://static.anjunabeats.com/anjunabeats-worldwide/podcast.xml
|
||||
http://www.coldwiredmusic.com/coldwiredmusic/podcast.xml
|
||||
http://www.silkmusicshowcase.com/?feed=silk-music-showcase
|
|
@ -0,0 +1,22 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<xsl:stylesheet version="1.1" xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
|
||||
xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd">
|
||||
<xsl:output method="html" omit-xml-declaration="yes"/>
|
||||
<xsl:template match="/rss/channel">
|
||||
<podcast title="{title}" link="{link}" image="{itunes:image/@href}">
|
||||
<xsl:for-each select="item">
|
||||
<xsl:if test="position()<10">
|
||||
<episode guid="{guid}" link="{link}" title="{title}" date="{pubDate}" url="{enclosure/@url}">
|
||||
<description>
|
||||
<xsl:value-of select="description"/>
|
||||
</description>
|
||||
<summary>
|
||||
<xsl:value-of select="itunes:summary"/>
|
||||
</summary>
|
||||
</episode>
|
||||
<xsl:text> </xsl:text>
|
||||
</xsl:if>
|
||||
</xsl:for-each>
|
||||
</podcast>
|
||||
</xsl:template>
|
||||
</xsl:stylesheet>
|
|
@ -0,0 +1,158 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||
<title>Document</title>
|
||||
<style>
|
||||
/* https://hankchizljaw.com/wrote/a-modern-css-reset/ */
|
||||
/* Box sizing rules */
|
||||
html {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Remove default padding */
|
||||
ul,
|
||||
ol {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Remove default margin */
|
||||
body,
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
p,
|
||||
ul,
|
||||
ol,
|
||||
li,
|
||||
figure,
|
||||
figcaption,
|
||||
blockquote,
|
||||
dl,
|
||||
dd,
|
||||
pre {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Set core body defaults */
|
||||
body {
|
||||
min-height: 100vh;
|
||||
scroll-behavior: smooth;
|
||||
text-rendering: optimizeSpeed;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Remove list styles on ul, ol elements with a class attribute */
|
||||
ul[class],
|
||||
ol[class] {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
/* A elements that don't have a class get default styles */
|
||||
a:not([class]) {
|
||||
text-decoration-skip-ink: auto;
|
||||
}
|
||||
|
||||
/* Make images easier to work with */
|
||||
img {
|
||||
max-width: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Natural flow and rhythm in articles by default */
|
||||
article > * + * {
|
||||
margin-top: 1em;
|
||||
}
|
||||
|
||||
/* Inherit fonts for inputs and buttons */
|
||||
input,
|
||||
button,
|
||||
textarea,
|
||||
select {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
/* Remove all animations and transitions for people that prefer not to see them */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
* {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* system font stack: https://css-tricks.com/snippets/css/system-font-stack/ */
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
|
||||
}
|
||||
|
||||
body {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
/* our stuff */
|
||||
#app {
|
||||
margin: 1rem;
|
||||
}
|
||||
|
||||
.player-details {
|
||||
height: 200px;
|
||||
display: flex;
|
||||
margin-top: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
.player-details__image {
|
||||
min-width: 200px;
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
}
|
||||
.player-details__description {
|
||||
overflow-y: auto;
|
||||
margin-left: 1rem;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #333;
|
||||
}
|
||||
.episode__playing {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
podcast {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.podcast {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.podcast__title {
|
||||
margin: 0 0 0 1rem;
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
.podcast__episodes {
|
||||
margin-left: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
line-height: 1.4rem;
|
||||
}
|
||||
|
||||
.episode__date {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.episode__description-separator {
|
||||
font-weight: bold;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
|
@ -0,0 +1,5 @@
|
|||
Zero-Clause BSD
|
||||
|
||||
Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
|
@ -0,0 +1,20 @@
|
|||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$(readlink -f "$0")")"
|
||||
|
||||
cache=".cache"
|
||||
mkdir -p "${cache}"
|
||||
|
||||
rm -f feed.html
|
||||
cat head.html > feed.html
|
||||
|
||||
while read -r url; do
|
||||
id=$(echo "${url}" | sha1sum -t | awk '{ print $1 }')
|
||||
echo "Updating ${url} ..."
|
||||
curl -fsSL -o "${cache}/${id}".xml "${url}"
|
||||
xsltproc feed.xsl "${cache}/${id}".xml >> feed.html
|
||||
done < feed-urls
|
||||
|
||||
cat tail.html >> feed.html
|
|
@ -0,0 +1,16 @@
|
|||
# podpage
|
||||
|
||||
A simple podcast fetcher and player.
|
||||
|
||||
# Usage
|
||||
|
||||
1. Edit `feed-urls` so it contains RSS feed URLs from your favorite podcasts.
|
||||
- Have an iTunes link and unsure what the RSS feed URL is for it? Use
|
||||
[this](https://getrssfeed.com/) to find out.
|
||||
2. Run `podpage`
|
||||
3. Open `feed.html` in your browser and enjoy!
|
||||
|
||||
----
|
||||
|
||||
You might consider setting up a cron job or such to update `feed.html` so you
|
||||
can listen new episodes on a regular basis.
|
|
@ -0,0 +1,193 @@
|
|||
<div id="app">
|
||||
<audio controls></audio>
|
||||
<div class="player-details">
|
||||
<img :src="currentPodcast.image"
|
||||
class="player-details__image">
|
||||
<pre class="player-details__description">
|
||||
{{ summaryOrDescription }}
|
||||
</pre>
|
||||
</div>
|
||||
<div class="podcasts">
|
||||
<div v-for="podcast in podcasts"
|
||||
class="podcast">
|
||||
<details open>
|
||||
<summary class="podcast__title">{{ podcast.title }}</summary>
|
||||
<ul class="podcast__episodes">
|
||||
<li v-for="episode in podcast.episodes"
|
||||
class="episode">
|
||||
<span class="episode__date">{{ episode.date }}</span>
|
||||
<span class="episode__description-separator"> / </span>
|
||||
<a @click.prevent="play(podcast, episode)"
|
||||
:class="linkCSS(episode)"
|
||||
:title="episode.title"
|
||||
href="#"
|
||||
>{{ trim(episode.title) }}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://unpkg.com/vue@next"></script>
|
||||
<script>
|
||||
const simpleDate = (date) => new Date(date).toISOString().split('T')[0];
|
||||
|
||||
function getPodcastsData() {
|
||||
const podcasts = [].slice.call(document.querySelectorAll('podcast')).map(podcastEl => {
|
||||
const podcast = {
|
||||
title: podcastEl.getAttribute('title'),
|
||||
link: podcastEl.getAttribute('link'),
|
||||
image: podcastEl.getAttribute('image'),
|
||||
};
|
||||
podcast.episodes = [].slice.call(podcastEl.querySelectorAll('episode')).map(episodeEl => ({
|
||||
guid: episodeEl.getAttribute('guid'),
|
||||
link: episodeEl.getAttribute('link'),
|
||||
title: episodeEl.getAttribute('title'),
|
||||
date: simpleDate(episodeEl.getAttribute('date')),
|
||||
url: episodeEl.getAttribute('url'),
|
||||
description: episodeEl.querySelector('description').textContent.split('\n').filter(s => s.trim()).map(line => line.replace(/^\s+/, '')).join('\n'),
|
||||
summary: episodeEl.querySelector('summary').textContent.split('\n').filter(s => s.trim()).map(line => line.replace(/^\s+/, '')).join('\n'),
|
||||
}));
|
||||
return podcast;
|
||||
});
|
||||
|
||||
// sort alphabetically
|
||||
// podcasts.sort((a, b) => a.title.toLowerCase() > b.title.toLowerCase() ? 1 : -1 );
|
||||
|
||||
return podcasts;
|
||||
}
|
||||
|
||||
let podcasts = getPodcastsData();
|
||||
// remove data nodes from DOM
|
||||
document.querySelectorAll('podcast').forEach(p => p.remove());
|
||||
|
||||
const Player = {
|
||||
audio: null,
|
||||
|
||||
init() {
|
||||
// in seconds
|
||||
const defaultSkipTime = 10;
|
||||
|
||||
navigator.mediaSession.setActionHandler('play', () => {
|
||||
this.audio.play();
|
||||
navigator.mediaSession.playbackState = 'playing';
|
||||
});
|
||||
navigator.mediaSession.setActionHandler('pause', () => {
|
||||
this.audio.pause();
|
||||
navigator.mediaSession.playbackState = 'paused';
|
||||
});
|
||||
navigator.mediaSession.setActionHandler('stop', () => {
|
||||
this.audio.pause();
|
||||
navigator.mediaSession.playbackState = 'paused';
|
||||
});
|
||||
navigator.mediaSession.setActionHandler('seekbackward', (event) => {
|
||||
const skipTime = event.seekOffset || defaultSkipTime;
|
||||
console.log(event);
|
||||
this.audio.currentTime = Math.max(this.audio.currentTime - skipTime, 0);
|
||||
});
|
||||
navigator.mediaSession.setActionHandler('seekforward', (event) => {
|
||||
const skipTime = event.seekOffset || defaultSkipTime;
|
||||
console.log(event);
|
||||
this.audio.currentTime = Math.max(audio.currentTime + skipTime, this.audio.duration);
|
||||
});
|
||||
navigator.mediaSession.setActionHandler('seekto', (event) => {
|
||||
if (event.fastSeek && ('fastSeek' in this.audio)) {
|
||||
this.audio.fastSeek(event.seekTime);
|
||||
return;
|
||||
}
|
||||
this.audio.currentTime = event.seekTime;
|
||||
});
|
||||
navigator.mediaSession.setActionHandler('previoustrack', () => {
|
||||
// TODO
|
||||
console.log('TODO previous track')
|
||||
});
|
||||
navigator.mediaSession.setActionHandler('nexttrack', () => {
|
||||
// TODO
|
||||
console.log('TODO next track')
|
||||
});
|
||||
},
|
||||
|
||||
async play({ title, artist, album, artworkURL, audioURL }) {
|
||||
this.audio.src = audioURL;
|
||||
await this.audio.play();
|
||||
|
||||
const artwork = [];
|
||||
// minimal effort here
|
||||
if (artworkURL) {
|
||||
let type = 'image/png';
|
||||
if (artworkURL.endsWith('jpg') || artworkURL.endsWith('jpeg')) {
|
||||
type = 'image/jpg';
|
||||
} else if (artworkURL.endsWith('png')) {
|
||||
type = 'image/png';
|
||||
}
|
||||
|
||||
const imageSize = await new Promise(resolve => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(`${img.width}x${img.height}`);
|
||||
img.src = artworkURL;
|
||||
});
|
||||
|
||||
artwork.push({
|
||||
src: artworkURL,
|
||||
sizes: imageSize,
|
||||
type,
|
||||
});
|
||||
}
|
||||
|
||||
navigator.mediaSession.metadata = new MediaMetadata({
|
||||
title,
|
||||
artist,
|
||||
album,
|
||||
artwork,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
const App = {
|
||||
created() {
|
||||
this.podcasts = podcasts;
|
||||
},
|
||||
mounted() {
|
||||
Player.audio = this.$el.parentNode.querySelector('audio');
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
currentEpisode: {},
|
||||
currentPodcast: {},
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
summaryOrDescription() {
|
||||
return (this.currentEpisode.summary || '').length > (this.currentEpisode.description || '').length
|
||||
? this.currentEpisode.summary
|
||||
: this.currentEpisode.description;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
play(podcast, episode) {
|
||||
if (this.currentEpisode.guid === episode.guid) {
|
||||
return
|
||||
}
|
||||
|
||||
this.currentPodcast = podcast;
|
||||
this.currentEpisode = episode;
|
||||
Player.play({
|
||||
title: episode.title,
|
||||
artist: 'TBD', // TODO scrape if possible
|
||||
album: podcast.title,
|
||||
artworkURL: podcast.image,
|
||||
audioURL: episode.url,
|
||||
});
|
||||
},
|
||||
linkCSS(episode) {
|
||||
return this.currentEpisode.guid === episode.guid ? 'episode__playing' : '';
|
||||
},
|
||||
trim(s) {
|
||||
return s.length <= 64 ? s : s.substring(0, 61) + '...';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Vue.createApp(App).mount('#app');
|
||||
</script>
|
Loading…
Reference in New Issue