Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions .github/workflows/build-test-and-docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,8 @@ jobs:
swift build --target NotesExample && \
swift build --target PathsExample && \
swift build --target WebViewExample && \
swift build --target AdvancedCustomizationExample
swift build --target AdvancedCustomizationExample && \
swift build --target MusicPlayerExample

- name: Test
run: swift test --test-product swift-cross-uiPackageTests
Expand Down Expand Up @@ -114,6 +115,7 @@ jobs:
buildtarget PathsExample
buildtarget WebViewExample
buildtarget AdvancedCustomizationExample
buildtarget MusicPlayerExample

if [ $device_type != TV ]; then
# Slider is not implemented for tvOS
Expand Down Expand Up @@ -179,6 +181,7 @@ jobs:
buildtarget RandomNumberGeneratorExample
buildtarget WebViewExample
buildtarget AdvancedCustomizationExample
buildtarget MusicPlayerExample
# TODO test whether this works on Catalyst
# buildtarget SplitExample

Expand Down Expand Up @@ -323,7 +326,8 @@ jobs:
swift build --target SpreadsheetExample && \
swift build --target NotesExample && \
swift build --target PathsExample && \
swift build --target AdvancedCustomizationExample
swift build --target AdvancedCustomizationExample && \
swift build --target MusicPlayerExample

- name: Test
run: swift test --test-product swift-cross-uiPackageTests
Expand Down
6 changes: 6 additions & 0 deletions Examples/Bundler.toml
Original file line number Diff line number Diff line change
Expand Up @@ -74,3 +74,9 @@ version = '0.1.0'
identifier = 'dev.swiftcrossui.AdvancedCustomizationExample'
product = 'AdvancedCustomizationExample'
version = '0.1.0'

[apps.MusicPlayerExample]
identifier = 'dev.swiftcrossui.MusicPlayerExample'
product = 'MusicPlayerExample'
version = '0.1.0'
icon = 'Icons/MusicPlayerExample.png'
Binary file added Examples/Icons/MusicPlayerExample.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
13 changes: 11 additions & 2 deletions Examples/Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 10 additions & 0 deletions Examples/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ let package = Package(
platforms: [.macOS(.v10_15), .iOS(.v13), .tvOS(.v13), .macCatalyst(.v13)],
dependencies: [
.package(name: "swift-cross-ui", path: ".."),
.package(
url: "https://github.com/stackotter/swift-miniaudio",
.upToNextMinor(from: "0.1.0")
),
] + hotReloadingDependencies,
targets: [
.executableTarget(
Expand Down Expand Up @@ -94,6 +98,12 @@ let package = Package(
name: "AdvancedCustomizationExample",
dependencies: exampleDependencies,
resources: [.copy("Banner.png")]
),
.executableTarget(
name: "MusicPlayerExample",
dependencies: [
.product(name: "MiniAudio", package: "swift-miniaudio")
] + exampleDependencies
)
]
)
20 changes: 20 additions & 0 deletions Examples/Sources/MusicPlayerExample/Config.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import Foundation

/// The user's configuration. Just contains the user's playlists for now.
struct Config: Codable {
/// The default configuration used when the configuration file is invalid
/// or missing.
static let `default` = Self(playlists: [], songs: [])

/// The user's playlists.
var playlists: [Playlist]

/// The user's songs
var songs: [Song]

func song(withId id: UUID) -> Song? {
songs.first { song in
song.id == id
}
}
}
28 changes: 28 additions & 0 deletions Examples/Sources/MusicPlayerExample/MediaControlButton.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import SwiftCrossUI

struct MediaControlButton: View {
@Environment(\.isEnabled) var isEnabled

var isPlaying: Bool

var playbackButtonColor: Color {
isEnabled ? .green : .gray
}

var body: some View {
Group {
if isPlaying {
applyShapeStyle(PauseButton())
} else {
applyShapeStyle(PlayButton())
}
}
}

func applyShapeStyle<S: Shape>(_ shape: S) -> some StyledShape {
shape.stroke(
playbackButtonColor,
style: StrokeStyle(width: 4, cap: .round, join: .round)
).fill(playbackButtonColor)
}
}
108 changes: 108 additions & 0 deletions Examples/Sources/MusicPlayerExample/MediaPlayer.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import Foundation
import SwiftCrossUI

class MediaPlayer: SwiftCrossUI.ObservableObject {
@SwiftCrossUI.Published
var currentSong: UUID?

/// The cursor of the current song in seconds.
@SwiftCrossUI.Published
var cursor: Double = 0

var progress: Double? {
if let duration = songFile?.duration, duration != 0 {
cursor / duration
} else {
nil
}
}

@SwiftCrossUI.Published
var isPlaying = false

private var songFile: SongStorage.SongFile?
private var progressTask: Task<Void, Never>?

func isPlayingSong(id: UUID) -> Bool {
id == currentSong && isPlaying
}

func play(_ songFile: SongStorage.SongFile, id: UUID) throws {
if id != currentSong {
// Rewind new song to the start if it's not the current song.
try songFile.sound.seek(toSecond: 0)

if let currentSongFile = self.songFile {
// Stop current song if the new song is different.
try currentSongFile.sound.stop()
}
}

cursor = try songFile.sound.cursor

// Make sure that the last fallible step is starting the song, otherwise
// errors could be kinda annoying.
try songFile.sound.start()

progressTask?.cancel()
progressTask = createProgressTask(for: songFile)

isPlaying = true
self.currentSong = id
self.songFile = songFile
}

func pause() throws {
if isPlaying, let songFile {
try songFile.sound.stop()
progressTask?.cancel()
isPlaying = false
}
}

func resume() throws {
if !isPlaying, let songFile {
try songFile.sound.start()
progressTask = createProgressTask(for: songFile)
isPlaying = true
}
}

/// If the current song is playing, pauses it, otherwise resumes it.
func toggleCurrentSong() throws {
if isPlaying {
try pause()
} else {
try resume()
}
}

/// Creates a task which updates the current song's progress at a given
/// frequency (updates per second).
private func createProgressTask(
for songFile: SongStorage.SongFile,
frequency: Double = 30
) -> Task<Void, Never> {
Task {
while true {
do {
try await Task.sleep(nanoseconds: UInt64(1 / frequency * 1_000_000_000))
} catch {
break
}

do {
let cursor = try songFile.sound.cursor
// TODO: Is this needed? If so, can SwiftCrossUI enforce it?
Task { @MainActor in
self.cursor = cursor
}
} catch {
// TODO: Propagate this warning to the user
print("warning: Failed to update song cursor")
break
}
}
}
}
}
88 changes: 88 additions & 0 deletions Examples/Sources/MusicPlayerExample/MusicPlayerApp.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import DefaultBackend
import Foundation
import SwiftCrossUI
import MiniAudio

#if canImport(SwiftBundlerRuntime)
import SwiftBundlerRuntime
#endif

@main
@HotReloadable
struct MusicPlayerApp: App {
@Environment(\.presentAlert) var presentAlert

@State var storage: Storage?
@State var initializationError: String?
@State var mediaPlayer = MediaPlayer()
var engine: Engine?

init() {
let identifier = Self.metadata?.identifier ?? "com.example.MusicPlayerExample"

let engine: Engine
do {
engine = try Engine()
} catch {
initializationError = "Failed to load MiniAudio engine: \(error.localizedDescription)"
return
}
self.engine = engine

let applicationSupport: URL
do {
applicationSupport = try FileManager.default.url(
for: .applicationSupportDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: true
)
} catch {
initializationError = """
Failed to get application support directory: \(error.localizedDescription)
"""
return
}

let directory = applicationSupport.appendingPathComponent(identifier)
let storage = Storage(directory: directory, engine: engine)
_storage = State(wrappedValue: storage)
}

var body: some Scene {
WindowGroup("Music Player") {
#hotReloadable {
content
}
}
}

var content: some View {
VStack {
if let initializationError {
Text(initializationError)
.frame(maxWidth: 400)
.font(.system(size: 20, weight: nil, design: nil))

Button("Exit app") {
Foundation.exit(1)
}
} else if let storage, storage.loaded {
RootView(storage: storage, mediaPlayer: mediaPlayer)
} else {
ProgressView("Loading")
}
}.task {
guard let storage else {
initializationError = "Failed to load storage: unknown reason"
return
}

do {
try storage.load()
} catch {
initializationError = "Failed to load config: \(error.localizedDescription)"
}
}
}
}
9 changes: 9 additions & 0 deletions Examples/Sources/MusicPlayerExample/MusicPlayerError.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import Foundation

struct MusicPlayerError: LocalizedError {
var message: String

var errorDescription: String? {
message
}
}
17 changes: 17 additions & 0 deletions Examples/Sources/MusicPlayerExample/PauseButton.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import SwiftCrossUI

struct PauseButton: Shape {
func path(in bounds: Path.Rect) -> Path {
let yInset: Double = 2
let xInset: Double = 4
return Path()
.move(to: [xInset, yInset])
.addLine(to: [xInset, bounds.maxY - yInset])
.move(to: [bounds.maxX - xInset, yInset])
.addLine(to: [bounds.maxX - xInset, bounds.maxY - yInset])
}

func size(fitting proposal: ProposedViewSize) -> ViewSize {
ViewSize(17, 22)
}
}
16 changes: 16 additions & 0 deletions Examples/Sources/MusicPlayerExample/PlayButton.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import SwiftCrossUI

struct PlayButton: Shape {
func path(in bounds: Path.Rect) -> Path {
let inset: Double = 2
return Path()
.move(to: [inset, inset])
.addLine(to: [bounds.maxX - inset, bounds.y + bounds.height / 2])
.addLine(to: [inset, bounds.maxY - inset])
.addLine(to: [inset, inset])
}

func size(fitting proposal: ProposedViewSize) -> ViewSize {
ViewSize(17, 22)
}
}
Loading
Loading