Skip to content

Remember folder #1

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 7 commits into
base: main
Choose a base branch
from
Open
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 Coldwave.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
objects = {

/* Begin PBXBuildFile section */
8723BCF32A78C706007BC8FF /* LocationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8723BCF22A78C706007BC8FF /* LocationView.swift */; };
F823C8CB2709C06E00CB6F31 /* AlbumCoverView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F823C8CA2709C06E00CB6F31 /* AlbumCoverView.swift */; };
F823C8CD2709CBA500CB6F31 /* PlaybackControlView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F823C8CC2709CBA500CB6F31 /* PlaybackControlView.swift */; };
F84AB2DB269329D400D69D4F /* ColdwaveApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = F84AB2DA269329D400D69D4F /* ColdwaveApp.swift */; };
Expand Down Expand Up @@ -37,6 +38,7 @@
/* End PBXCopyFilesBuildPhase section */

/* Begin PBXFileReference section */
8723BCF22A78C706007BC8FF /* LocationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationView.swift; sourceTree = "<group>"; };
F823C8CA2709C06E00CB6F31 /* AlbumCoverView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlbumCoverView.swift; sourceTree = "<group>"; };
F823C8CC2709CBA500CB6F31 /* PlaybackControlView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaybackControlView.swift; sourceTree = "<group>"; };
F84AB2D7269329D400D69D4F /* Coldwave.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Coldwave.app; sourceTree = BUILT_PRODUCTS_DIR; };
Expand Down Expand Up @@ -110,6 +112,7 @@
F86C1E3A270C476600D602A6 /* SearchView.swift */,
F86C1E3C270C8F5800D602A6 /* ColdwaveState.swift */,
F86C1E3E270CA5B200D602A6 /* SingleAlbumView.swift */,
8723BCF22A78C706007BC8FF /* LocationView.swift */,
);
path = Coldwave;
sourceTree = "<group>";
Expand Down Expand Up @@ -202,6 +205,7 @@
F86C1E3D270C8F5800D602A6 /* ColdwaveState.swift in Sources */,
F823C8CB2709C06E00CB6F31 /* AlbumCoverView.swift in Sources */,
F84AB2FC26934D5000D69D4F /* ContentView.swift in Sources */,
8723BCF32A78C706007BC8FF /* LocationView.swift in Sources */,
F823C8CD2709CBA500CB6F31 /* PlaybackControlView.swift in Sources */,
F86C1E3F270CA5B200D602A6 /* SingleAlbumView.swift in Sources */,
);
Expand Down Expand Up @@ -341,7 +345,7 @@
"$(inherited)",
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 11.1;
MACOSX_DEPLOYMENT_TARGET = 11.0;
PRODUCT_BUNDLE_IDENTIFIER = abyrd.Coldwave;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
Expand All @@ -364,7 +368,7 @@
"$(inherited)",
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 11.1;
MACOSX_DEPLOYMENT_TARGET = 11.0;
PRODUCT_BUNDLE_IDENTIFIER = abyrd.Coldwave;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
Expand Down
4 changes: 2 additions & 2 deletions Coldwave/Album.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ class Album: Identifiable, Equatable {
init (_ albumFullPath: String) {
albumPath = albumFullPath
let fileManager = FileManager.default
let contents = try! fileManager.contentsOfDirectory(atPath: albumFullPath)
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Order folders by path

let contents = try! fileManager.contentsOfDirectory(atPath: albumFullPath).sorted()
for file in contents as [String] {
var isDir: ObjCBool = false;
let lcFile = file.lowercased()
Expand Down Expand Up @@ -94,7 +94,7 @@ class Album: Identifiable, Equatable {
// Return an array of URLs, one for each music file in this album's folder.
func getPlaylist () -> [URL] {
let fileManager = FileManager.default
let contents = try! fileManager.contentsOfDirectory(atPath: albumPath)
let contents = try! fileManager.contentsOfDirectory(atPath: albumPath).sorted()
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Order tracks by name (usually the track starts with a track number)

var musicFileURLs: [URL] = []
for file in contents as [String] {
let lcFile = file.lowercased()
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file not shown.
4 changes: 4 additions & 0 deletions Coldwave/Assets.xcassets/AppIcon.appiconset/Contents.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,21 +21,25 @@
"size" : "32x32"
},
{
"filename" : "AppIcon128x128.png",
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add some icons, so I know where the app is.

"idiom" : "mac",
"scale" : "1x",
"size" : "128x128"
},
{
"filename" : "[email protected]",
"idiom" : "mac",
"scale" : "2x",
"size" : "128x128"
},
{
"filename" : "AppIcon256x256.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "256x256"
},
{
"filename" : "[email protected]",
"idiom" : "mac",
"scale" : "2x",
"size" : "256x256"
Expand Down
12 changes: 8 additions & 4 deletions Coldwave/Coldwave.entitlements
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,13 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.files.user-selected.read-only</key>
<true/>
<key>com.apple.security.app-sandbox</key>
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some of this is needed in order to make the "remember folder" work (since there is no user action.
I think there is a way to make the application ask for access to Documents, Downloads and maybe Music folder instead of forcing them here, but I am not sure of how to do that.

<true/>
<key>com.apple.security.assets.music.read-only</key>
<true/>
<key>com.apple.security.files.downloads.read-only</key>
<true/>
<key>com.apple.security.files.user-selected.read-only</key>
<true/>
</dict>
</plist>
43 changes: 41 additions & 2 deletions Coldwave/ColdwaveApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,13 @@ import SwiftUI

@main
struct ColdwaveApp: App {

@NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate

@StateObject var state: ColdwaveState = ColdwaveState()

// Only available in macOS 13.0 and later
//@Environment(\.openWindow) private var openWindow

var body: some Scene {
WindowGroup {
ContentView(state: state)
Expand All @@ -13,6 +17,11 @@ struct ColdwaveApp: App {
CommandGroup(before: CommandGroupPlacement.newItem) {
Button(action: { openFolder() }, label: { Label("Open directory...", systemImage: "doc") })
.keyboardShortcut("o")

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is to add a "modal" dialog to ask for a remote location (audio stream or remote file)

/*
Button(action: { openWindow(id: "open-location") }, label: { Label("Open location...", systemImage: "doc")})
.keyboardShortcut("l")
*/
}
CommandMenu("Utilities") {
Button("Bigger") {
Expand All @@ -23,8 +32,14 @@ struct ColdwaveApp: App {
}.keyboardShortcut("-")
}
}

/*
Window("Open location", id: "open-location") {
LocationView()
}.windowResizability(.contentSize)
*/
}

private func openFolder () {
let dialog = NSOpenPanel();
dialog.title = "Choose root music directory | ABCD";
Expand All @@ -44,3 +59,27 @@ struct ColdwaveApp: App {
}

}

final class AppDelegate: NSObject, NSApplicationDelegate {
func applicationDidFinishLaunching(_ notification: Notification) {
let unwantedMenus = ["Edit","View" ]
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove a couple of menu entries that don't do much for a music player


let removeMenus = {
unwantedMenus.forEach {
guard let menu = NSApp.mainMenu?.item(withTitle: $0) else { return }
NSApp.mainMenu?.removeItem(menu)
}
}

NotificationCenter.default.addObserver(
forName: NSMenu.didAddItemNotification,
object: nil,
queue: .main
) { _ in
// Must refresh after every time SwiftUI re adds
removeMenus()
}

removeMenus()
}
}
140 changes: 136 additions & 4 deletions Coldwave/ColdwaveState.swift
Original file line number Diff line number Diff line change
@@ -1,21 +1,31 @@
import Foundation
import AVFoundation
import SwiftUI
import UserNotifications
import MediaPlayer

class ColdwaveState: ObservableObject {


@AppStorage("music.folder") var path = ""
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

retrieve last folder path from application store

@Published var albums: [Album] = []
@Published var path: String = "";
//@Published var path = "";
@Published var currentAlbum: Album?
@Published var currentTrack = 0
@Published var currentTitle: String = ""
@Published var coverSize: CGFloat = DEFAULT_IMAGE_SIZE
@Published var playlist: [URL] = []
@Published var amountPlayed: Double = 0.0 // in range 0...1
@Published var playing: Bool = false
@Published var searchText: String = ""
@Published var timePlayed = 0
@Published var timeRemaining = 0

let player: AVPlayer = AVPlayer()
let npCenter: MPNowPlayingInfoCenter = MPNowPlayingInfoCenter.default()

init() {
player.allowsExternalPlayback = true

player.addPeriodicTimeObserver(
forInterval: CMTime(seconds: 0.5, preferredTimescale: CMTimeScale(NSEC_PER_SEC)),
queue: .main
Expand All @@ -24,10 +34,23 @@ class ColdwaveState: ObservableObject {
// converting the track duration units to match the amountPlayed units.
if let duration = self.player.currentItem?.duration.convertScale(t.timescale, method: CMTimeRoundingMethod.default) {
self.amountPlayed = Double(t.value) / Double(duration.value)
self.timePlayed = Int(t.seconds)
let d = duration.seconds
self.timeRemaining = d.isNaN ? 0 : Int(d - t.seconds)

self.updateNpInfo()
}
}

npCenter.playbackState = .stopped
// setupRemoteControls()
requestNotifications()

if path != "" {
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If app is starting up and there is a stored folder path, scan library

albums = Album.scanLibrary(at: path)
}
}

// It doesn't seem clean to put this (or the AVPlayer) on the state, but notification
// targets have to be objc functions which have to be members of an NSObject or protocol.
// I could probably factor the player field and these methods out into another class.
Expand All @@ -47,6 +70,7 @@ class ColdwaveState: ObservableObject {
let track = AVPlayerItem(asset: AVAsset(url: playlist[trackNumber]))
player.replaceCurrentItem(with: track)
currentTrack = trackNumber;
currentTitle = playlist[trackNumber].lastPathComponent
// I seem to be getting double-starts on automatic transition to next track.
// But removing the play() call causes it to stall on the transition.
player.play()
Expand All @@ -58,22 +82,130 @@ class ColdwaveState: ObservableObject {
name: NSNotification.Name.AVPlayerItemDidPlayToEndTime,
object: track
)

npCenter.playbackState = .playing
updateNpInfo()
} else {
player.pause()
playing = false
npCenter.playbackState = .stopped
updateNpInfo()
}
}

func pause () {
player.pause()
playing = false
npCenter.playbackState = .stopped
}

func play () {
if (player.currentItem != nil) {
player.play()
playing = true
npCenter.playbackState = .playing
}
}

func requestNotifications() {
let center = UNUserNotificationCenter.current()
center.requestAuthorization(options: [.alert, /* .sound, .badge,*/ .provisional]) { granted, error in
if error != nil {
NSLog("UN requestAuthoriziation %@", error!.localizedDescription)
} else {
NSLog("UN requestAuthorization %@", granted ? "granted" : "denied")
}
}
}

func notify(title : String, message : String) {
let center = UNUserNotificationCenter.current()
center.getNotificationSettings { settings in
guard (settings.authorizationStatus == .authorized) ||
(settings.authorizationStatus == .provisional) else {
NSLog("Notifications not authorizided nor provisional")
return
}

if settings.alertSetting == .enabled {
let content = UNMutableNotificationContent()
content.title = title
content.body = message
content.categoryIdentifier = "abyrd.Coldwave"

let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 0.1, repeats: false)
let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: trigger)
center.add(request)
} else {
NSLog("Alert notifications are disabled")
}
}
}

/*
private func setupRemoteControls() {
let commandCenter = MPRemoteCommandCenter.shared()

commandCenter.pauseCommand.addTarget { (event) -> MPRemoteCommandHandlerStatus in
NSLog("remote pause")
notify(title: config!.title, message: "Pause")
playerPause()
return .success
}
commandCenter.playCommand.addTarget { (event) -> MPRemoteCommandHandlerStatus in
NSLog("remote play")
if current < 0 {
playNext()
} else {
playerPlay()
}
return .success
}
commandCenter.togglePlayPauseCommand.addTarget { (event) -> MPRemoteCommandHandlerStatus in
NSLog("remote playPause")
if player.timeControlStatus == .playing {
notify(title: config!.title, message: "Pause " + currentTitle())
playerPause()
} else {
if current < 0 {
playNext()
} else {
playerPlay()
}

notify(title: config!.title, message: "Play " + currentTitle())
}
return .success
}
commandCenter.nextTrackCommand.addTarget { (event) -> MPRemoteCommandHandlerStatus in
NSLog("remote next")
let index = selectNext(forward: true)
playerSelect(index: index, play: true)
notify(title: config!.title, message: "Play " + currentTitle())
return .success
}
commandCenter.previousTrackCommand.addTarget { (event) -> MPRemoteCommandHandlerStatus in
NSLog("remote next")
let index = selectNext(forward: false)
playerSelect(index: index, play: true)
notify(title: config!.title, message: "Play " + currentTitle())
return .success
}
}
*/

private func updateNpInfo() {
npCenter.nowPlayingInfo = [
MPNowPlayingInfoPropertyMediaType: MPNowPlayingInfoMediaType.audio.rawValue,
MPNowPlayingInfoPropertyIsLiveStream: false,
//MPNowPlayingInfoPropertyPlaybackRate: player.rate,
MPMediaItemPropertyTitle: currentTitle,
MPMediaItemPropertyPodcastTitle: currentTitle,
MPNowPlayingInfoPropertyAssetURL: playlist[currentTrack],
MPMediaItemPropertyArtist: currentAlbum?.artist ?? "",
//MPMediaItemPropertyPlaybackDuration: player.currentItem?.duration as Any,
//MPNowPlayingInfoPropertyElapsedPlaybackTime: player.currentTime(),
//MPMediaItemPropertyArtwork: currentAlbum?.coverImagePath as Any,
]
}
}
Loading