Add single file audio player, introduce blocks

This commit is contained in:
Christoph Hagen
2025-01-06 01:17:06 +01:00
parent c78c359819
commit 245534e989
27 changed files with 521 additions and 88 deletions

View File

@ -9,6 +9,13 @@ extension String {
.replacingOccurrences(of: ">", with: ">")
}
func percentDecoded() -> String {
guard let decoded = removingPercentEncoding else {
return self
}
return decoded
}
var removingSurroundingQuotes: String {
if hasPrefix("\"") && hasSuffix("\"") {
return dropBeforeFirst("\"").dropAfterLast("\"")
@ -85,6 +92,14 @@ extension String {
func between(_ start: String, and end: String) -> String {
dropBeforeFirst(start).dropAfterFirst(end)
}
/**
Split the string at the first occurence of the separator
*/
func splitAtFirst(_ separator: String) -> (String, String) {
let parts = components(separatedBy: separator)
return (parts.first!, parts.dropFirst().joined(separator: separator))
}
}
extension String {
@ -129,4 +144,12 @@ extension Substring {
func last(after: String) -> String {
components(separatedBy: after).last!
}
/**
Split the string at the first occurence of the separator
*/
func splitAtFirst(_ separator: String) -> (String, String) {
let parts = components(separatedBy: separator)
return (parts.first!, parts.dropFirst().joined(separator: separator))
}
}

View File

@ -0,0 +1,62 @@
struct AudioBlockProcessor: KeyedBlockProcessor {
enum Key: String {
case name
case artist
case album
case file
case cover
}
static let blockId: ContentBlock = .audio
let content: Content
let results: PageGenerationResults
let language: ContentLanguage
init(content: Content, results: PageGenerationResults, language: ContentLanguage) {
self.content = content
self.results = results
self.language = language
}
func process(_ arguments: [Key : String], markdown: Substring) -> String {
guard let name = arguments[.name],
let artist = arguments[.artist],
let album = arguments[.album],
let fileId = arguments[.file],
let cover = arguments[.cover] else {
invalid(markdown)
return ""
}
guard let image = content.image(cover) else {
results.missing(file: cover, source: "Audio Block")
return ""
}
guard let file = content.file(fileId) else {
results.missing(file: fileId, source: "Audio Block")
return ""
}
let coverSize = 2 * content.settings.audioPlayer.smallCoverImageSize
let coverImage = image.imageVersion(width: coverSize, height: coverSize, type: image.type)
let footer = SingleFilePlayer.footer(
name: name,
artist: artist,
album: album,
url: file.absoluteUrl,
cover: coverImage.outputPath)
results.require(image: coverImage)
results.require(footer: footer)
results.require(headers: .audioPlayerJs, .audioPlayerCss)
results.require(icons: .audioPlayerPlay, .audioPlayerPause)
return SingleFilePlayer().content
}
}

View File

@ -0,0 +1,86 @@
enum ContentBlock: String, CaseIterable {
case audio
case swift
var processor: BlockProcessor.Type {
switch self {
case .audio: return AudioBlockProcessor.self
case .swift: return SwiftBlockProcessor.self
}
}
}
protocol BlockProcessor {
static var blockId: ContentBlock { get }
var results: PageGenerationResults { get }
init(content: Content, results: PageGenerationResults, language: ContentLanguage)
func process(_ markdown: Substring) -> String
}
extension BlockProcessor {
func invalid(_ markdown: Substring) {
results.invalid(block: Self.blockId, markdown)
}
}
protocol BlockLineProcessor: BlockProcessor {
func process(_ lines: [String], markdown: Substring) -> String
}
extension BlockLineProcessor {
func process(_ markdown: Substring) -> String {
let lines = markdown
.between("```\(Self.blockId.self)", and: "```")
.components(separatedBy: "\n")
return process(lines, markdown: markdown)
}
}
protocol OrderedKeyBlockProcessor: BlockLineProcessor {
associatedtype Key: Hashable, RawRepresentable where Key.RawValue == String
func process(_ arguments: [(key: Key, value: String)], markdown: Substring) -> String
}
extension OrderedKeyBlockProcessor {
func process(_ lines: [String], markdown: Substring) -> String {
let result: [(key: Key, value: String)] = lines.compactMap { line in
guard line.trimmed != "" else {
return nil
}
let (rawKey, rawValue) = line.splitAtFirst(":")
guard let key = Key(rawValue: rawKey.trimmed) else {
print("Invalid key \(rawKey)")
invalid(markdown)
return nil
}
return (key, rawValue.trimmed)
}
return process(result, markdown: markdown)
}
}
protocol KeyedBlockProcessor: OrderedKeyBlockProcessor {
func process(_ arguments: [Key : String], markdown: Substring) -> String
}
extension KeyedBlockProcessor {
func process(_ arguments: [(key: Key, value: String)], markdown: Substring) -> String {
let result = arguments.reduce(into: [:]) { $0[$1.key] = $1.value }
return process(result, markdown: markdown)
}
}

View File

@ -0,0 +1,33 @@
struct CodeBlockProcessor {
private let codeHighlightFooter = "<script>hljs.highlightAll();</script>"
let results: PageGenerationResults
private let blocks: [ContentBlock : BlockProcessor]
private let other: OtherCodeProcessor
init(content: Content, results: PageGenerationResults, language: ContentLanguage) {
self.results = results
self.other = .init(results: results)
self.blocks = ContentBlock.allCases.reduce(into: [:]) { blocks, block in
blocks[block] = block.processor.init(content: content, results: results, language: language)
}
}
func process(_ html: String, markdown: Substring) -> String {
let input = String(markdown)
let rawBlockId = input.dropAfterFirst("\n").dropBeforeFirst("```").trimmed
guard let blockId = ContentBlock(rawValue: rawBlockId) else {
return other.process(html: html)
}
guard let processor = self.blocks[blockId] else {
results.invalid(block: blockId, markdown)
return ""
}
return processor.process(markdown)
}
}

View File

@ -0,0 +1,17 @@
struct OtherCodeProcessor {
private let codeHighlightFooter = "<script>hljs.highlightAll();</script>"
let results: PageGenerationResults
init(results: PageGenerationResults) {
self.results = results
}
func process(html: String) -> String {
results.require(header: .codeHightlighting)
results.require(footer: codeHighlightFooter)
return html // Just use normal code highlighting
}
}

View File

@ -0,0 +1,26 @@
import Splash
struct SwiftBlockProcessor: BlockProcessor {
static let blockId: ContentBlock = .swift
let content: Content
let results: PageGenerationResults
let language: ContentLanguage
private let swift = SyntaxHighlighter(format: HTMLOutputFormat())
init(content: Content, results: PageGenerationResults, language: ContentLanguage) {
self.content = content
self.results = results
self.language = language
}
func process(_ markdown: Substring) -> String {
// Highlight swift code using Splash
let code = markdown.between("```swift", and: "```").trimmed
return "<pre><code>" + swift.highlight(code) + "</pre></code>"
}
}

View File

@ -31,6 +31,9 @@ final class GenerationResults: ObservableObject {
@Published
var invalidCommands: Set<String> = []
@Published
var invalidBlocks: Set<String> = []
@Published
var warnings: Set<String> = []
@ -105,6 +108,8 @@ final class GenerationResults: ObservableObject {
update { self.imagesToGenerate = imagesToGenerate }
let invalidCommands = cache.values.map { $0.invalidCommands.map { $0.markdown }}.union()
update { self.invalidCommands = invalidCommands }
let invalidBlocks = cache.values.map { $0.invalidBlocks.map { $0.markdown }}.union()
update { self.invalidBlocks = invalidBlocks }
let warnings = cache.values.map { $0.warnings }.union()
update { self.warnings = warnings }
let unsavedOutputFiles = cache.values.map { $0.unsavedOutputFiles.keys }.union()
@ -163,6 +168,10 @@ final class GenerationResults: ObservableObject {
update { self.invalidCommands.insert(markdown) }
}
func invalidBlock(_ markdown: String) {
update { self.invalidBlocks.insert(markdown) }
}
func warning(_ warning: String) {
update { self.warnings.insert(warning) }
}

View File

@ -26,11 +26,11 @@ enum KnownHeaderElement: Int {
return HeaderElement.jsModule(file)
}
case .audioPlayerCss:
if let file = content.settings.pages.audioPlayerCssFile {
if let file = content.settings.audioPlayer.audioPlayerCssFile {
return .css(file: file, order: HeaderElement.audioPlayerCssOrder)
}
case .audioPlayerJs:
if let file = content.settings.pages.audioPlayerJsFile {
if let file = content.settings.audioPlayer.audioPlayerJsFile {
return .js(file: file, defer: true)
}
case .imageCompareJs:

View File

@ -46,7 +46,7 @@ struct AudioPlayerCommandProcessor: CommandProcessor {
var amplitude: [AmplitudeSong] = []
for song in songs {
guard let image = content.image(song.cover) else {
guard let image = content.file(song.cover) else {
results.missing(file: song.cover, containedIn: file)
continue
}
@ -63,7 +63,11 @@ struct AudioPlayerCommandProcessor: CommandProcessor {
results.warning("Song '\(song.file)' in file \(fileId) is not an audio file")
continue
}
let coverUrl = image.absoluteUrl
let coverSize = 2 * content.settings.audioPlayer.playlistCoverImageSize
let coverImage = image.imageVersion(width: coverSize, height: coverSize, type: image.type)
let coverUrl = coverImage.outputPath
results.require(image: coverImage)
let playlistItem = AudioPlayer.PlaylistItem(
index: playlist.count,

View File

@ -13,7 +13,6 @@ struct ButtonCommandProcessor: CommandProcessor {
}
/**
Format: `![buttons](<<fileId>,<text>,<download-filename?>;...)`
Format: `![buttons](type=<specification>;...)`
Types:
- Download: `download=<fileId>,<text>,<download-filename?>`

View File

@ -1,25 +0,0 @@
import Splash
struct PageCodeProcessor {
private let codeHighlightFooter = "<script>hljs.highlightAll();</script>"
private let swift = SyntaxHighlighter(format: HTMLOutputFormat())
let results: PageGenerationResults
init(results: PageGenerationResults) {
self.results = results
}
func process(_ html: String, markdown: Substring) -> String {
guard markdown.starts(with: "```swift") else {
results.require(header: .codeHightlighting)
results.require(footer: codeHighlightFooter)
return html // Just use normal code highlighting
}
// Highlight swift code using Splash
let code = markdown.between("```swift", and: "```").trimmed
return "<pre><code>" + swift.highlight(code) + "</pre></code>"
}
}

View File

@ -33,7 +33,7 @@ final class PageContentParser {
private let inlineLink: InlineLinkProcessor
private let code: PageCodeProcessor
private let code: CodeBlockProcessor
init(content: Content, language: ContentLanguage, results: PageGenerationResults) {
self.content = content
@ -50,7 +50,7 @@ final class PageContentParser {
self.images = .init(content: content, results: results, language: language)
self.inlineLink = .init(content: content, results: results, language: language)
self.code = .init(results: results)
self.code = .init(content: content, results: results, language: language)
}
func generatePage(from content: String) -> String {
@ -84,21 +84,12 @@ final class PageContentParser {
return parts[0] + " id=\"\(id)\">" + parts.dropFirst().joined(separator: ">")
}
private func percentDecoded(_ string: String) -> String {
guard let decoded = string.removingPercentEncoding else {
print("Invalid string: \(string)")
return string
}
return decoded
}
private func processMarkdownImage(html: String, markdown: Substring) -> String {
//
let argumentList = percentDecoded(markdown.between(first: "](", andLast: ")"))
let argumentList = markdown.between(first: "](", andLast: ")").percentDecoded()
let arguments = argumentList.components(separatedBy: ";")
let rawCommand = percentDecoded(markdown.between("![", and: "]").trimmed)
let rawCommand = markdown.between("![", and: "]").trimmed.percentDecoded()
guard rawCommand != "" else {
return images.process(arguments, markdown: markdown)
}

View File

@ -66,7 +66,9 @@ final class PageGenerationResults: ObservableObject {
/// The image versions required for this page
private(set) var imagesToGenerate: Set<ImageVersion>
private(set) var invalidCommands: [(command: ShorthandMarkdownKey?, markdown: String)] = []
private(set) var invalidCommands: [(command: ShorthandMarkdownKey?, markdown: String)]
private(set) var invalidBlocks: [(block: ContentBlock?, markdown: String)]
private(set) var warnings: Set<String>
@ -94,6 +96,7 @@ final class PageGenerationResults: ObservableObject {
requiredFiles = []
imagesToGenerate = []
invalidCommands = []
invalidBlocks = []
warnings = []
unsavedOutputFiles = [:]
pageIsEmpty = false
@ -116,6 +119,7 @@ final class PageGenerationResults: ObservableObject {
requiredFiles = []
imagesToGenerate = []
invalidCommands = []
invalidBlocks = []
warnings = []
unsavedOutputFiles = [:]
pageIsEmpty = false
@ -134,6 +138,12 @@ final class PageGenerationResults: ObservableObject {
delegate.invalidCommand(markdown)
}
func invalid(block: ContentBlock?, _ markdown: Substring) {
let markdown = String(markdown)
invalidBlocks.append((block, markdown))
delegate.invalidBlock(markdown)
}
func missing(page: String, source: String) {
missingLinkedPages[page, default: []].insert(source)
delegate.missing(page: page)

View File

@ -17,8 +17,16 @@ enum ShorthandMarkdownKey: String {
/// Format: `![video](<fileId>;<option1...>]`
case video
/// A variable number of download buttons for file downloads
/// Format: `[buttons](type=<<fileId>,<text>,<download-filename?>;...)`
/**
A variable number of buttons for file downloads, external links or other uses
Format: `![buttons](type=<specification>;...)`
Types:
- Download: `download=<fileId>,<text>,<download-filename?>`
- External link: `external=<url>,<text>`
- Git: `git=<url>,<text>`
- Play: `play-circle=<text>,<click-action>`
*/
case buttons
/// A box with a title and content

View File

@ -0,0 +1,49 @@
import Foundation
final class AudioPlayerSettings: ObservableObject {
@Published
var playlistCoverImageSize: Int
@Published
var smallCoverImageSize: Int
@Published
var audioPlayerJsFile: FileResource?
@Published
var audioPlayerCssFile: FileResource?
init(playlistCoverImageSize: Int,
smallCoverImageSize: Int,
audioPlayerJsFile: FileResource?,
audioPlayerCssFile: FileResource?) {
self.playlistCoverImageSize = playlistCoverImageSize
self.smallCoverImageSize = smallCoverImageSize
self.audioPlayerJsFile = audioPlayerJsFile
self.audioPlayerCssFile = audioPlayerCssFile
}
init(file: AudioPlayerSettingsFile, files: [String : FileResource]) {
self.playlistCoverImageSize = file.playlistCoverImageSize
self.smallCoverImageSize = file.smallCoverImageSize
self.audioPlayerJsFile = file.audioPlayerJsFile.map { files[$0] }
self.audioPlayerCssFile = file.audioPlayerCssFile.map { files[$0] }
}
var file: AudioPlayerSettingsFile {
.init(playlistCoverImageSize: playlistCoverImageSize,
smallCoverImageSize: smallCoverImageSize,
audioPlayerJsFile: audioPlayerJsFile?.id,
audioPlayerCssFile: audioPlayerCssFile?.id)
}
}
extension AudioPlayerSettings {
static let `default`: AudioPlayerSettings = .init(
playlistCoverImageSize: 280,
smallCoverImageSize: 78,
audioPlayerJsFile: nil,
audioPlayerCssFile: nil)
}

View File

@ -17,12 +17,6 @@ final class PageSettings: ObservableObject {
@Published
var codeHighlightingJsFile: FileResource?
@Published
var audioPlayerJsFile: FileResource?
@Published
var audioPlayerCssFile: FileResource?
@Published
var modelViewerJsFile: FileResource?
@ -38,8 +32,6 @@ final class PageSettings: ObservableObject {
self.pageLinkImageSize = file.pageLinkImageSize
self.defaultCssFile = file.defaultCssFile.map { files[$0] }
self.codeHighlightingJsFile = file.codeHighlightingJsFile.map { files[$0] }
self.audioPlayerJsFile = file.audioPlayerJsFile.map { files[$0] }
self.audioPlayerCssFile = file.audioPlayerCssFile.map { files[$0] }
self.modelViewerJsFile = file.modelViewerJsFile.map { files[$0] }
self.imageCompareCssFile = file.imageCompareCssFile.map { files[$0] }
self.imageCompareJsFile = file.imageCompareJsFile.map { files[$0] }
@ -51,8 +43,6 @@ final class PageSettings: ObservableObject {
pageLinkImageSize: pageLinkImageSize,
defaultCssFile: defaultCssFile?.id,
codeHighlightingJsFile: codeHighlightingJsFile?.id,
audioPlayerJsFile: audioPlayerJsFile?.id,
audioPlayerCssFile: audioPlayerCssFile?.id,
modelViewerJsFile: modelViewerJsFile?.id,
imageCompareJsFile: imageCompareJsFile?.id,
imageCompareCssFile: imageCompareCssFile?.id)

View File

@ -15,17 +15,28 @@ final class Settings: ObservableObject {
@Published
var pages: PageSettings
@Published
var audioPlayer: AudioPlayerSettings
@Published
var german: LocalizedPostSettings
@Published
var english: LocalizedPostSettings
init(paths: PathSettings, navigation: NavigationSettings, posts: PostSettings, pages: PageSettings, german: LocalizedPostSettings, english: LocalizedPostSettings) {
init(paths: PathSettings,
navigation: NavigationSettings,
posts: PostSettings,
pages: PageSettings,
audioPlayer: AudioPlayerSettings,
german: LocalizedPostSettings,
english: LocalizedPostSettings) {
self.paths = paths
self.navigation = navigation
self.posts = posts
self.pages = pages
self.audioPlayer = audioPlayer
self.german = german
self.english = english
}
@ -43,6 +54,7 @@ final class Settings: ObservableObject {
self.posts = PostSettings(file: file.posts, files: files)
self.pages = PageSettings(file: file.pages, files: files)
self.paths = PathSettings(file: file.paths)
self.audioPlayer = .init(file: file.audioPlayer, files: files)
self.german = .init(file: file.german)
self.english = .init(file: file.english)
@ -54,6 +66,7 @@ final class Settings: ObservableObject {
navigation: navigation.file,
posts: posts.file,
pages: pages.file,
audioPlayer: audioPlayer.file,
german: german.file,
english: english.file)
}
@ -66,6 +79,7 @@ extension Settings {
navigation: .default,
posts: .default,
pages: .default,
audioPlayer: .default,
german: .german,
english: .english)
}

View File

@ -0,0 +1,45 @@
struct SingleFilePlayer: HtmlProducer {
func populate(_ result: inout String) {
result += "<div class='song-container'>"
result += "<div class='song-player'>"
result += "<div class='song-cover-container amplitude-play-pause amplitude-paused'>"
result += "<div class='song-player-button'>"
result += "<svg class='play-icon'><use href='#\(AudioPlayerPlayIcon.name)'></use></svg>"
result += "<svg class='pause-icon'><use href='#\(AudioPlayerPauseIcon.name)'></use></svg>"
result += "</div>"
result += "<img data-amplitude-song-info='cover_art_url' class='article-thumbnail-image' width='78' height='78'></img>"
result += "</div>"
result += "<div class='song-info'>"
result += "<div class='song-timeline'>"
result += "<span class='amplitude-current-time song-current-time'></span>"
result += "<span class='amplitude-duration-time song-duration-time'></span>"
result += "</div>"
result += "<div class='song-progress'>"
result += "<input type='range' class='amplitude-song-slider song-slider'>"
result += "<progress id='song-played-progress' class=' amplitude-song-played-progress song-progress-bar'></progress>"
result += "</div>"
result += "<span data-amplitude-song-info='name'></span>"
result += "<span data-amplitude-song-info='album' style='font-weight: 500;'></span>"
result += "</div></div></div>"
}
static func footer(name: String, artist: String, album: String, url: String, cover: String) -> String {
"""
<script>
window.onload = () => {
Amplitude.init({
"songs": [{
"name": "\(name)",
"artist": "\(artist)",
"album": "\(album)",
"url": "\(url)",
"cover_art_url": "\(cover)"
}]
});
};
</script>
"""
}
}

View File

@ -0,0 +1,11 @@
struct AudioPlayerSettingsFile: Codable {
let playlistCoverImageSize: Int
let smallCoverImageSize: Int
let audioPlayerJsFile: String?
let audioPlayerCssFile: String?
}

View File

@ -11,10 +11,6 @@ struct PageSettingsFile {
let codeHighlightingJsFile: String?
let audioPlayerJsFile: String?
let audioPlayerCssFile: String?
let modelViewerJsFile: String?
let imageCompareJsFile: String?
@ -34,8 +30,6 @@ extension PageSettingsFile {
pageLinkImageSize: 180,
defaultCssFile: nil,
codeHighlightingJsFile: nil,
audioPlayerJsFile: nil,
audioPlayerCssFile: nil,
modelViewerJsFile: nil,
imageCompareJsFile: nil,
imageCompareCssFile: nil)

View File

@ -11,6 +11,8 @@ struct SettingsFile {
let pages: PageSettingsFile
let audioPlayer: AudioPlayerSettingsFile
let german: LocalizedPostSettingsFile
let english: LocalizedPostSettingsFile
@ -26,6 +28,7 @@ extension SettingsFile {
navigation: .default,
posts: .default,
pages: .default,
audioPlayer: AudioPlayerSettings.default.file,
german: .default,
english: .default
)

View File

@ -0,0 +1,43 @@
import SwiftUI
struct AudioSettingsDetailView: View {
@Environment(\.language)
private var language
@ObservedObject
var audioPlayer: AudioPlayerSettings
var body: some View {
ScrollView {
VStack(alignment: .leading) {
DetailTitle(
title: "Audio Player Settings",
text: "Configure the files and settings for the audio player components")
IntegerPropertyView(
title: "Playlist Cover Image Size",
value: $audioPlayer.playlistCoverImageSize,
footer: "The maximum size of the album cover image in a playlist audio player (in pixels)")
IntegerPropertyView(
title: "Small Album Cover Image Size",
value: $audioPlayer.smallCoverImageSize,
footer: "The maximum size of the album cover image in a single file audio player (in pixels)")
FilePropertyView(
title: "Audio Player CSS File",
footer: "The CSS file to provide the style for the audio player",
selectedFile: $audioPlayer.audioPlayerCssFile,
allowedType: .asset)
FilePropertyView(
title: "Audio Player JavaScript File",
footer: "The CSS file to provide the functionality for the audio player",
selectedFile: $audioPlayer.audioPlayerJsFile,
allowedType: .asset)
}
.padding()
}
}
}

View File

@ -17,10 +17,10 @@ struct GenerationContentView: View {
var body: some View {
switch selectedSection {
case .folders, .navigationBar, .postFeed, .tagOverview:
generationView
case .pages:
PageSettingsContentView()
default:
generationView
}
}
@ -96,6 +96,11 @@ struct GenerationContentView: View {
Text(markdown)
}
}
Section("Invalid blocks") {
ForEach(content.results.invalidBlocks.sorted(), id: \.self) { markdown in
Text(markdown)
}
}
Section("Warnings") {
ForEach(content.results.warnings.sorted(), id: \.self) { warning in
Text(warning)

View File

@ -4,6 +4,9 @@ struct GenerationDetailView: View {
let section: SettingsSection
@EnvironmentObject
private var content: Content
var body: some View {
switch section {
case .folders:
@ -16,6 +19,8 @@ struct GenerationDetailView: View {
PageSettingsDetailView()
case .tagOverview:
TagOverviewDetailView()
case .audioPlayer:
AudioSettingsDetailView(audioPlayer: content.settings.audioPlayer)
}
}
}

View File

@ -42,18 +42,6 @@ struct PageSettingsDetailView: View {
selectedFile: $content.settings.pages.codeHighlightingJsFile,
allowedType: .asset)
FilePropertyView(
title: "Audio Player CSS File",
footer: "The CSS file to provide the style for the audio player",
selectedFile: $content.settings.pages.audioPlayerCssFile,
allowedType: .asset)
FilePropertyView(
title: "Audio Player JavaScript File",
footer: "The CSS file to provide the functionality for the audio player",
selectedFile: $content.settings.pages.audioPlayerJsFile,
allowedType: .asset)
FilePropertyView(
title: "3D Model Viewer File",
footer: "The JavaScript file to provide the functionality for the 3D model viewer",

View File

@ -12,6 +12,8 @@ enum SettingsSection: String {
case tagOverview = "Tag Overview"
case audioPlayer = "Audio Player"
}
extension SettingsSection {
@ -23,6 +25,7 @@ extension SettingsSection {
case .postFeed: return .rectangleGrid1x2
case .pages: return .docRichtext
case .tagOverview: return .tag
case .audioPlayer: return .waveform
}
}
}