Initial commit

This commit is contained in:
Jonathan Curran 2020-10-20 21:21:08 -06:00
commit e0e446cca0
8 changed files with 419 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
/.cache/
/feed.html

3
feed-urls Normal file
View File

@ -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

22
feed.xsl Normal file
View File

@ -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()&lt;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>&#10;</xsl:text>
</xsl:if>
</xsl:for-each>
</podcast>
</xsl:template>
</xsl:stylesheet>

158
head.html Normal file
View File

@ -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>

5
license.md Normal file
View File

@ -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.

20
podpage Executable file
View File

@ -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

16
readme.md Normal file
View File

@ -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.

193
tail.html Normal file
View File

@ -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>