diff --git a/Melodee/Classes/FilesystemManager.swift b/Melodee/Classes/FilesystemManager.swift index 6d9ab96..bce610b 100644 --- a/Melodee/Classes/FilesystemManager.swift +++ b/Melodee/Classes/FilesystemManager.swift @@ -114,6 +114,7 @@ class FilesystemManager { case "txt": return .text case "pdf": return .pdf case "zip": return .zip + case "melodee": return .playlist case "icloud": return fileType(for: url.deletingPathExtension()) default: return nil } @@ -126,6 +127,7 @@ class FilesystemManager { case "txt": return .text case "pdf": return .pdf case "zip": return .zip + case "melodee": return .playlist default: return .notSet } } diff --git a/Melodee/Classes/MediaPlayerManager.swift b/Melodee/Classes/MediaPlayerManager.swift index 259010d..dfbcaba 100644 --- a/Melodee/Classes/MediaPlayerManager.swift +++ b/Melodee/Classes/MediaPlayerManager.swift @@ -270,8 +270,7 @@ class MediaPlayerManager: NSObject, AVAudioPlayerDelegate { let albumArt = await albumArt() var nowPlayingInfo = [String: Any]() nowPlayingInfo[MPMediaItemPropertyTitle] = currentlyPlayingTitle() ?? "" - nowPlayingInfo[MPMediaItemPropertyArtist] = Bundle.main - .object(forInfoDictionaryKey: "CFBundleDisplayName") as? String ?? "" + nowPlayingInfo[MPMediaItemPropertyArtist] = "Melodee" nowPlayingInfo[MPMediaItemPropertyArtwork] = MPMediaItemArtwork(boundsSize: albumArt.size) { _ in return albumArt } diff --git a/Melodee/Classes/PlaylistManager.swift b/Melodee/Classes/PlaylistManager.swift new file mode 100644 index 0000000..dd39260 --- /dev/null +++ b/Melodee/Classes/PlaylistManager.swift @@ -0,0 +1,87 @@ +// +// PlaylistManager.swift +// Melodee +// +// Created by シン・ジャスティン on 2024/03/16. +// + +import Foundation + +@Observable +class PlaylistManager { + + static let playlistExtension = "melodee" + + // MARK: - Load / Save + + static func load(from url: URL) -> Playlist? { + guard let data = try? Data(contentsOf: url) else { return nil } + return try? JSONDecoder().decode(Playlist.self, from: data) + } + + static func save(_ playlist: Playlist, to url: URL) { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + guard let data = try? encoder.encode(playlist) else { return } + try? data.write(to: url, options: .atomic) + } + + // MARK: - Create + + static func create(name: String, in directoryURL: URL, audioFiles: [FSFile]) -> URL { + let sanitizedName = name.replacingOccurrences(of: "/", with: "-") + let fileURL = directoryURL + .appendingPathComponent(sanitizedName) + .appendingPathExtension(playlistExtension) + + let playlistFiles = audioFiles.compactMap { file -> PlaylistFile? in + guard let relativePath = relativePath( + from: directoryURL, + to: URL(fileURLWithPath: file.path) + ) else { return nil } + return PlaylistFile(relativePath: relativePath) + } + let playlist = Playlist(name: name, files: playlistFiles) + save(playlist, to: fileURL) + return fileURL + } + + /// Computes a relative path from a directory to a file. + static func relativePath(from base: URL, to target: URL) -> String? { + let basePath = base.standardizedFileURL.path(percentEncoded: false) + let targetPath = target.standardizedFileURL.path(percentEncoded: false) + + let baseComponents = basePath.split(separator: "/", omittingEmptySubsequences: true) + let targetComponents = targetPath.split(separator: "/", omittingEmptySubsequences: true) + + // Find common prefix length + var commonLength = 0 + while commonLength < baseComponents.count && commonLength < targetComponents.count + && baseComponents[commonLength] == targetComponents[commonLength] { + commonLength += 1 + } + + // Number of ".." needed to go up from base + let ups = baseComponents.count - commonLength + var parts: [String] = Array(repeating: "..", count: ups) + // Append remaining target components + parts.append(contentsOf: targetComponents[commonLength...].map(String.init)) + + let result = parts.joined(separator: "/") + return result.isEmpty ? nil : result + } + + // MARK: - Helpers + + static func directoryURL(for playlistFileURL: URL) -> URL { + playlistFileURL.deletingLastPathComponent() + } + + static func isPlaylistFile(_ url: URL) -> Bool { + url.pathExtension.lowercased() == playlistExtension + } + + static func isPlaylistFile(_ file: FSFile) -> Bool { + file.extension.lowercased() == playlistExtension + } +} diff --git a/Melodee/Enums/FileType.swift b/Melodee/Enums/FileType.swift index 3428c72..b13d5b1 100644 --- a/Melodee/Enums/FileType.swift +++ b/Melodee/Enums/FileType.swift @@ -14,6 +14,7 @@ enum FileType: String, Codable { case text case pdf case zip + case playlist case notSet func icon() -> Image { @@ -22,6 +23,7 @@ enum FileType: String, Codable { case .image: return Image("File.Image") case .pdf: return Image("File.PDF") case .zip: return Image("File.Archive") + case .playlist: return Image(systemName: "music.note.list") default: return Image("File.Generic") } } diff --git a/Melodee/Enums/ViewPath.swift b/Melodee/Enums/ViewPath.swift index d53fba0..ac451fe 100644 --- a/Melodee/Enums/ViewPath.swift +++ b/Melodee/Enums/ViewPath.swift @@ -12,6 +12,7 @@ enum ViewPath: Hashable { case imageViewer(file: FSFile) case textViewer(file: FSFile) case pdfViewer(file: FSFile) + case playlistViewer(file: FSFile) case tagEditorSingle(file: FSFile) case tagEditorMultiple(files: [FSFile]) case moreAttributions diff --git a/Melodee/InfoPlist.xcstrings b/Melodee/InfoPlist.xcstrings index d9ffdcc..a08c825 100644 --- a/Melodee/InfoPlist.xcstrings +++ b/Melodee/InfoPlist.xcstrings @@ -9,12 +9,6 @@ "state" : "translated", "value" : "Melodee" } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "メロディー" - } } } }, @@ -26,12 +20,6 @@ "state" : "translated", "value" : "Melodee" } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "メロディー" - } } } }, @@ -54,4 +42,4 @@ } }, "version" : "1.0" -} \ No newline at end of file +} diff --git a/Melodee/Localizable.xcstrings b/Melodee/Localizable.xcstrings index 3e43f77..676698a 100644 --- a/Melodee/Localizable.xcstrings +++ b/Melodee/Localizable.xcstrings @@ -839,13 +839,13 @@ "ipad" : { "stringUnit" : { "state" : "translated", - "value" : "メロディーにファイルを追加するには、「ファイル」アプリを開き、「このiPad内」の「メロディー」フォルダーにファイルを移動してください。" + "value" : "Melodeeにファイルを追加するには、「ファイル」アプリを開き、「このiPad内」の「Melodee」フォルダーにファイルを移動してください。" } }, "other" : { "stringUnit" : { "state" : "translated", - "value" : "メロディーにファイルを追加するには、「ファイル」アプリを開き、「このiPhone内」の「メロディー」フォルダーにファイルを移動してください。" + "value" : "Melodeeにファイルを追加するには、「ファイル」アプリを開き、「このiPhone内」の「Melodee」フォルダーにファイルを移動してください。" } } } @@ -937,7 +937,7 @@ "ja" : { "stringUnit" : { "state" : "translated", - "value" : "メロディーにファイルを追加する方法" + "value" : "Melodeeにファイルを追加する方法" } }, "ko" : { @@ -1867,6 +1867,211 @@ } } }, + "Playlists.Export" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Export" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "エクスポート" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "내보내기" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Xuất" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "导出" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "匯出" + } + } + } + }, + "Playlists.Export.JSON" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "As JSON" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "JSONとして" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "JSON으로" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dạng JSON" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "导出为JSON" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "匯出為JSON" + } + } + } + }, + "Playlists.Export.M3U8" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "As M3U8" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "M3U8として" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "M3U8로" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dạng M3U8" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "导出为M3U8" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "匯出為M3U8" + } + } + } + }, + "Playlists.Import" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Import Playlist" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "再生リストをインポート" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "재생목록 가져오기" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nhập danh sách phát" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "导入播放列表" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "匯入播放清單" + } + } + } + }, + "Playlists.Import.Error" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Import Error" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "インポートエラー" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "가져오기 오류" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lỗi nhập" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "导入错误" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "匯入錯誤" + } + } + } + }, "Playlists.Detail.Empty.Description" : { "extractionState" : "manual", "localizations" : { @@ -2072,6 +2277,88 @@ } } }, + "Playlists.NoAudioFiles.Description" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "There are no audio files in this scope." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "この範囲にオーディオファイルがありません。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이 범위에 오디오 파일이 없습니다." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Không có tệp âm thanh nào trong phạm vi này." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "此范围内没有音频文件。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "此範圍內沒有音訊檔案。" + } + } + } + }, + "Playlists.NoAudioFiles.Title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No Audio Files" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "オーディオファイルなし" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "오디오 파일 없음" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Không có tệp âm thanh" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "没有音频文件" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "沒有音訊檔案" + } + } + } + }, "Playlists.NoItems" : { "extractionState" : "manual", "localizations" : { @@ -2236,6 +2523,88 @@ } } }, + "Playlists.SelectAll" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Select All" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "すべて選択" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "모두 선택" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chọn tất cả" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "全选" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "全選" + } + } + } + }, + "Playlists.Songs" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Songs" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "曲" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "노래" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bài hát" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "歌曲" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "歌曲" + } + } + } + }, "Playlists.SongCount" : { "extractionState" : "manual", "localizations" : { diff --git a/Melodee/Structs/FSFile.swift b/Melodee/Structs/FSFile.swift index b24459e..913bf65 100644 --- a/Melodee/Structs/FSFile.swift +++ b/Melodee/Structs/FSFile.swift @@ -43,7 +43,7 @@ struct FSFile: FilesystemObject { } catch { debugPrint(error.localizedDescription) } - return Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String ?? "" + return "Melodee" } /// Returns true if this file is a taggable audio file (MP3 or M4A) diff --git a/Melodee/Structs/PlaylistItem.swift b/Melodee/Structs/PlaylistItem.swift index 9e555ff..08eb47e 100644 --- a/Melodee/Structs/PlaylistItem.swift +++ b/Melodee/Structs/PlaylistItem.swift @@ -6,120 +6,81 @@ // import Foundation -import SwiftData -@Model -final class Playlist { +struct Playlist: Codable { var name: String - @Relationship(deleteRule: .cascade, inverse: \PlaylistFileBookmark.playlist) - var fileBookmarks: [PlaylistFileBookmark] + var files: [PlaylistFile] - init(name: String) { + init(name: String, files: [PlaylistFile] = []) { self.name = name - self.fileBookmarks = [] + self.files = files } - /// Returns bookmarks sorted by their order index - var sortedBookmarks: [PlaylistFileBookmark] { - fileBookmarks.sorted { $0.order < $1.order } - } + // MARK: - M3U8 Export - /// Resolves all file bookmarks into FSFile objects, skipping any that fail to resolve. - func resolveFiles() -> [FSFile] { - var results: [FSFile] = [] - for bookmark in sortedBookmarks { - if let file = bookmark.resolveFile() { - results.append(file) - } + func toM3U8() -> String { + var lines: [String] = ["#EXTM3U", "#PLAYLIST:\(name)"] + for file in files { + let fileName = URL(fileURLWithPath: file.relativePath) + .deletingPathExtension().lastPathComponent + lines.append("#EXTINF:-1,\(fileName)") + lines.append(file.relativePath) } - return results + return lines.joined(separator: "\n") + "\n" } - /// Returns the first taggable audio file (MP3 or M4A) for album art, or nil if none exist. - func firstTaggableAudioFile() -> FSFile? { - for bookmark in sortedBookmarks { - if let file = bookmark.resolveFile(), file.isTaggableAudio() { - return file + // MARK: - M3U8 Import + + static func fromM3U8(content: String) -> (name: String?, relativePaths: [String]) { + var name: String? + var paths: [String] = [] + + for line in content.components(separatedBy: .newlines) { + let trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed.isEmpty || trimmed.hasPrefix("#EXTINF") { + continue + } + if trimmed.hasPrefix("#PLAYLIST:") { + name = String(trimmed.dropFirst("#PLAYLIST:".count)) + continue } + if trimmed.hasPrefix("#") { + continue + } + // Skip absolute paths + if trimmed.hasPrefix("/") || trimmed.contains("://") { + continue + } + paths.append(trimmed) } - return nil - } - - /// Adds a file bookmark at the end of the playlist - func addFile(url: URL) throws { - let bookmarkData = try url.bookmarkData( - options: .minimalBookmark, - includingResourceValuesForKeys: nil, - relativeTo: nil - ) - let nextOrder = (fileBookmarks.map(\.order).max() ?? -1) + 1 - let fileName = url.deletingPathExtension().lastPathComponent - let fileExtension = url.pathExtension.lowercased() - let bookmark = PlaylistFileBookmark( - order: nextOrder, - bookmarkData: bookmarkData, - fileName: fileName, - fileExtension: fileExtension - ) - fileBookmarks.append(bookmark) + return (name, paths) } } -@Model -final class PlaylistFileBookmark { - var order: Int - var bookmarkData: Data - var fileName: String - var fileExtension: String - var playlist: Playlist? +struct PlaylistFile: Codable, Identifiable, Hashable { + var relativePath: String + + var id: String { relativePath } - init(order: Int, bookmarkData: Data, fileName: String, fileExtension: String) { - self.order = order - self.bookmarkData = bookmarkData - self.fileName = fileName - self.fileExtension = fileExtension + var fileName: String { + URL(fileURLWithPath: relativePath).deletingPathExtension().lastPathComponent } - /// Resolves the bookmark data into a URL, re-saving if stale. - /// Returns nil if the bookmark cannot be resolved or the file doesn't exist. - func resolveURL() -> URL? { - do { - var isStale = false - let url = try URL( - resolvingBookmarkData: bookmarkData, - options: .withoutUI, - relativeTo: nil, - bookmarkDataIsStale: &isStale - ) - if isStale { - // Re-save the bookmark data - if let newBookmarkData = try? url.bookmarkData( - options: .minimalBookmark, - includingResourceValuesForKeys: nil, - relativeTo: nil - ) { - bookmarkData = newBookmarkData - } - } - guard FileManager.default.fileExists(atPath: url.path(percentEncoded: false)) else { - return nil - } - return url - } catch { - debugPrint("Failed to resolve bookmark: \(error.localizedDescription)") - return nil - } + var fileExtension: String { + URL(fileURLWithPath: relativePath).pathExtension.lowercased() } - /// Resolves the bookmark into an FSFile, or returns nil on failure. - func resolveFile() -> FSFile? { - guard let url = resolveURL() else { return nil } - let fileExtension = url.pathExtension.lowercased() - let fileType = FilesystemManager.fileType(forExtension: fileExtension) + func resolve(relativeTo baseURL: URL) -> FSFile? { + let fileURL = baseURL.appendingPathComponent(relativePath) + guard FileManager.default.fileExists(atPath: fileURL.path(percentEncoded: false)) else { + return nil + } + let ext = fileURL.pathExtension.lowercased() + let fileType = FilesystemManager.fileType(forExtension: ext) return FSFile( - name: url.deletingPathExtension().lastPathComponent, - extension: fileExtension, - path: url.path(percentEncoded: false), + name: fileURL.deletingPathExtension().lastPathComponent, + extension: ext, + path: fileURL.path(percentEncoded: false), type: fileType ) } diff --git a/Melodee/View Modifiers/FBNavigationDestination.swift b/Melodee/View Modifiers/FBNavigationDestination.swift index d44eeef..0069bb3 100644 --- a/Melodee/View Modifiers/FBNavigationDestination.swift +++ b/Melodee/View Modifiers/FBNavigationDestination.swift @@ -24,6 +24,8 @@ struct FileBrowserNavigationDestinations: ViewModifier { TextViewerView(file: file) case .pdfViewer(let file): PDFViewerView(file: file) + case .playlistViewer(let file): + PlaylistDetailView(file: file) case .tagEditorSingle(let file): TagEditorView(files: [file]) case .tagEditorMultiple(let files): diff --git a/Melodee/Views/App.swift b/Melodee/Views/App.swift index 681d838..1655e6a 100644 --- a/Melodee/Views/App.swift +++ b/Melodee/Views/App.swift @@ -5,7 +5,6 @@ // Created by シン・ジャスティン on 2023/09/11. // -import SwiftData import SwiftUI @main @@ -26,6 +25,5 @@ struct MelodeeApp: App { .environment(mediaPlayerManager) .environment(nowPlayingBarManager) } - .modelContainer(for: Playlist.self) } } diff --git a/Melodee/Views/Files/FBPlaylistFileRow.swift b/Melodee/Views/Files/FBPlaylistFileRow.swift new file mode 100644 index 0000000..d75ab02 --- /dev/null +++ b/Melodee/Views/Files/FBPlaylistFileRow.swift @@ -0,0 +1,22 @@ +// +// FBPlaylistFileRow.swift +// Melodee +// +// Created by シン・ジャスティン on 2024/03/16. +// + +import SwiftUI + +struct FBPlaylistFileRow: View { + + @State var file: FSFile + + var body: some View { + NavigationLink(value: ViewPath.playlistViewer(file: file)) { + ListFileRow(file: .constant(file)) + .tint(.primary) + } + // WARN: Will crash on iOS 18 if built with Xcode < 26.1 + .navigationLinkIndicatorVisibility(.hidden) + } +} diff --git a/Melodee/Views/Files/FolderView.swift b/Melodee/Views/Files/FolderView.swift index b853314..6d922fb 100644 --- a/Melodee/Views/Files/FolderView.swift +++ b/Melodee/Views/Files/FolderView.swift @@ -21,6 +21,7 @@ struct FolderView: View { @State var state = FBState() @State var isSelectingExternalDirectory = false @State var storageLocation: StorageLocation = .local + @State var isCreatingPlaylist = false var overrideStorageLocation: StorageLocation? @@ -117,6 +118,7 @@ struct FolderView: View { case .text: FBTextFileRow(file: file) case .pdf: FBPdfFileRow(file: file) case .zip: FBZipFileRow(file: file) { extractZIP(file: file) } + case .playlist: FBPlaylistFileRow(file: file) default: ListFileRow(file: .constant(file)) } } @@ -145,6 +147,16 @@ struct FolderView: View { ) ) .toolbar { + ToolbarItemGroup(placement: .topBarTrailing) { + Button { + isCreatingPlaylist = true + } label: { + Image(systemName: "plus") + } + } + if #available(iOS 26.0, *) { + ToolbarSpacer(.fixed, placement: .topBarTrailing) + } ToolbarItemGroup(placement: .topBarTrailing) { if folderContainsTaggableFiles() { FBMenu(files: $files) @@ -237,6 +249,33 @@ struct FolderView: View { sortFiles() } .fileBrowserAlerts(state: $state, refreshFiles: refreshFiles) + .sheet(isPresented: $isCreatingPlaylist) { + CreatePlaylistSheet( + scopeRootURL: scopeRootURL(), + saveDirectoryURL: currentDirectoryURL(), + fileManager: fileManager + ) { + refreshFiles() + } + } + } + + func currentDirectoryURL() -> URL { + if let currentDirectory { + return URL(fileURLWithPath: currentDirectory.path) + } + return scopeRootURL() + } + + func scopeRootURL() -> URL { + switch storageLocation { + case .local: + return fileManager.documentsDirectoryURL ?? FileManager.default.temporaryDirectory + case .cloud: + return fileManager.cloudDocumentsDirectoryURL ?? FileManager.default.temporaryDirectory + case .external: + return fileManager.directory ?? FileManager.default.temporaryDirectory + } } func updateFileManagerDirectory() { diff --git a/Melodee/Views/Image Viewer/ZoomableImageView.swift b/Melodee/Views/Image Viewer/ZoomableImageView.swift index bbe4803..942801b 100644 --- a/Melodee/Views/Image Viewer/ZoomableImageView.swift +++ b/Melodee/Views/Image Viewer/ZoomableImageView.swift @@ -13,7 +13,6 @@ import Zoomy struct ZoomableImageView: UIViewControllerRepresentable { static let analyzer = ImageAnalyzer() - nonisolated(unsafe) static let configuration = ImageAnalyzer.Configuration([.text, .machineReadableCode]) let imagePath: String @@ -31,9 +30,9 @@ struct ZoomableImageView: UIViewControllerRepresentable { // Configure image view with zoom viewController.addZoombehavior(for: imageView, settings: .noZoomCancellingSettings) // Configure Live Text for image view - let analyzerConfig = ZoomableImageView.configuration Task { do { + let analyzerConfig = ImageAnalyzer.Configuration([.text, .machineReadableCode]) let interaction = ImageAnalysisInteraction() let analysis = try await ZoomableImageView.analyzer .analyze(uiImage, configuration: analyzerConfig) diff --git a/Melodee/Views/MainTabView.swift b/Melodee/Views/MainTabView.swift index 84c0156..4a3d82e 100644 --- a/Melodee/Views/MainTabView.swift +++ b/Melodee/Views/MainTabView.swift @@ -49,10 +49,7 @@ struct MainTabView: View { } label: { Label(externalFolderTabTitleFormatted(), systemImage: "folder.fill") } - Tab("Tab.Playlists", systemImage: "music.note.list", value: 3) { - PlaylistsView() - } - Tab("Tab.More", systemImage: "ellipsis", value: 4) { + Tab("Tab.More", systemImage: "ellipsis", value: 3) { MoreView() } } diff --git a/Melodee/Views/Playlists/CreatePlaylistSheet.swift b/Melodee/Views/Playlists/CreatePlaylistSheet.swift new file mode 100644 index 0000000..821d623 --- /dev/null +++ b/Melodee/Views/Playlists/CreatePlaylistSheet.swift @@ -0,0 +1,256 @@ +// +// CreatePlaylistSheet.swift +// Melodee +// +// Created by シン・ジャスティン on 2024/03/16. +// + +@preconcurrency import AVFoundation +import SwiftUI + +struct CreatePlaylistSheet: View { + + @Environment(\.dismiss) var dismiss + + var scopeRootURL: URL + var saveDirectoryURL: URL + var fileManager: FilesystemManager + var onCreated: () -> Void + + @State var playlistName: String = "" + @State var allAudioFiles: [FSFile] = [] + @State var selectedFiles: Set = [] + @State var thumbnail: UIImage? + @State var isLoading: Bool = true + @FocusState var isNameFieldFocused: Bool + + var body: some View { + NavigationStack { + List { + // MARK: - Header + Section { + VStack(spacing: 12.0) { + // Album art thumbnail + Group { + if let thumbnail { + Image(uiImage: thumbnail) + .resizable() + .scaledToFill() + } else { + Image("Album.Generic") + .resizable() + .scaledToFill() + } + } + .frame(width: 140.0, height: 140.0) + .clipShape(RoundedRectangle(cornerRadius: 12.0)) + .shadow(radius: 4.0) + + // Name field + TextField("Playlists.PlaylistName", text: $playlistName) + .font(.title2.bold()) + .multilineTextAlignment(.center) + .focused($isNameFieldFocused) + } + .frame(maxWidth: .infinity) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + .padding(.vertical, 8.0) + } + + // MARK: - Song list + if isLoading { + Section { + HStack { + Spacer() + ProgressView() + Spacer() + } + .listRowBackground(Color.clear) + } + } else if allAudioFiles.isEmpty { + Section { + ContentUnavailableView { + Label("Playlists.NoAudioFiles.Title", systemImage: "music.note") + } description: { + Text("Playlists.NoAudioFiles.Description") + } + .listRowBackground(Color.clear) + } + } else { + Section { + Button { + if selectedFiles.count == allAudioFiles.count { + selectedFiles.removeAll() + } else { + selectedFiles = Set(allAudioFiles.map(\.path)) + } + } label: { + HStack { + Image(systemName: selectedFiles.count == allAudioFiles.count + ? "checkmark.circle.fill" : "circle") + .foregroundStyle(selectedFiles.count == allAudioFiles.count + ? .accent : .secondary) + .imageScale(.large) + Text("Playlists.SelectAll") + .foregroundStyle(.primary) + Spacer() + Text("\(selectedFiles.count)/\(allAudioFiles.count)") + .foregroundStyle(.secondary) + .font(.subheadline) + } + } + .listRowBackground(Color.clear) + + ForEach(allAudioFiles, id: \.path) { file in + Button { + toggleSelection(file) + } label: { + HStack(spacing: 12.0) { + Image(systemName: selectedFiles.contains(file.path) + ? "checkmark.circle.fill" : "circle") + .foregroundStyle(selectedFiles.contains(file.path) + ? .accent : .secondary) + .imageScale(.large) + VStack(alignment: .leading, spacing: 2.0) { + Text(file.name) + .font(.body) + .foregroundStyle(.primary) + .lineLimit(1) + Text(relativeDisplayPath(for: file)) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + Spacer() + } + } + .listRowBackground(Color.clear) + } + } header: { + Text("Playlists.Songs") + } + } + } + .listStyle(.plain) + .scrollContentBackground(.hidden) + .background( + .linearGradient( + colors: [.backgroundGradientTop, .backgroundGradientBottom], + startPoint: .top, + endPoint: .bottom + ) + ) + .navigationTitle("Playlists.CreatePlaylist") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Shared.Cancel") { + dismiss() + } + } + ToolbarItem(placement: .confirmationAction) { + Button("Shared.Save") { + createPlaylist() + dismiss() + } + .bold() + .disabled(playlistName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + || selectedFiles.isEmpty) + } + } + .onAppear { + isNameFieldFocused = true + loadAllAudioFiles() + } + .task { + await loadThumbnail() + } + } + } + + func loadAllAudioFiles() { + let allFiles = fileManager.files(in: scopeRootURL) + var audioFiles: [FSFile] = [] + collectAudioFiles(from: allFiles, into: &audioFiles) + allAudioFiles = audioFiles.sorted { + $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending + } + selectedFiles = Set(allAudioFiles.map(\.path)) + isLoading = false + } + + func collectAudioFiles(from objects: [any FilesystemObject], into result: inout [FSFile]) { + for object in objects { + if let file = object as? FSFile, file.type == .audio { + result.append(file) + } else if let directory = object as? FSDirectory { + collectAudioFiles(from: directory.files, into: &result) + } + } + } + + /// Display path relative to scope root (e.g. "subfolder/song.mp3") + func relativeDisplayPath(for file: FSFile) -> String { + let filePath = file.path + let rootPath = scopeRootURL.path(percentEncoded: false) + if filePath.hasPrefix(rootPath) { + var relative = String(filePath.dropFirst(rootPath.count)) + if relative.hasPrefix("/") { + relative = String(relative.dropFirst()) + } + return relative + } + return "\(file.name).\(file.extension)" + } + + func toggleSelection(_ file: FSFile) { + if selectedFiles.contains(file.path) { + selectedFiles.remove(file.path) + } else { + selectedFiles.insert(file.path) + } + } + + func createPlaylist() { + let trimmedName = playlistName.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedName.isEmpty else { return } + + let selected = allAudioFiles.filter { selectedFiles.contains($0.path) } + _ = PlaylistManager.create( + name: trimmedName, + in: saveDirectoryURL, + audioFiles: selected + ) + onCreated() + } + + func loadThumbnail() async { + // Wait for files to load + while isLoading { + try? await Task.sleep(for: .milliseconds(100)) + } + guard let firstAudio = allAudioFiles.first(where: { $0.isTaggableAudio() }) else { return } + let url = URL(fileURLWithPath: firstAudio.path) + do { + let asset = AVURLAsset(url: url) + let metadataList = try await asset.load(.metadata) + for item in metadataList { + switch item.commonKey { + case .commonKeyArtwork?: + if let data = try await item.load(.dataValue), + let image = UIImage(data: data), + let thumb = await image.byPreparingThumbnail( + ofSize: CGSize(width: 280.0, height: 280.0) + ) { + thumbnail = thumb + return + } + default: break + } + } + } catch { + debugPrint("Error loading thumbnail: \(error.localizedDescription)") + } + } +} diff --git a/Melodee/Views/Playlists/PlaylistDetailView.swift b/Melodee/Views/Playlists/PlaylistDetailView.swift index b20c176..1692297 100644 --- a/Melodee/Views/Playlists/PlaylistDetailView.swift +++ b/Melodee/Views/Playlists/PlaylistDetailView.swift @@ -12,14 +12,16 @@ import UniformTypeIdentifiers struct PlaylistDetailView: View { @Environment(MediaPlayerManager.self) var mediaPlayer - @Environment(\.modelContext) private var modelContext - var playlist: Playlist + /// The .melodee file + var file: FSFile + @State var playlist: Playlist? @State var resolvedFiles: [ResolvedPlaylistFile] = [] - @State var isAddingFiles: Bool = false @State var isRenamingPlaylist: Bool = false @State var editedPlaylistName: String = "" + @State var isExporting: Bool = false + @State var exportURL: URL? let statusBarHeight: CGFloat = UIApplication.shared.connectedScenes .filter { $0.activationState == .foregroundActive } @@ -31,6 +33,18 @@ struct PlaylistDetailView: View { @State var heightOfTitle: CGFloat = 1.0 @State var scrollOffset: CGFloat = 0.0 + var playlistName: String { + playlist?.name ?? file.name + } + + var fileURL: URL { + URL(fileURLWithPath: file.path) + } + + var baseURL: URL { + fileURL.deletingLastPathComponent() + } + var audioFiles: [ResolvedPlaylistFile] { resolvedFiles.filter { $0.file.type == .audio } } @@ -42,7 +56,7 @@ struct PlaylistDetailView: View { var body: some View { List { Section { - Text(playlist.name) + Text(playlistName) .font(.largeTitle) .textCase(.none) .bold() @@ -125,33 +139,40 @@ struct PlaylistDetailView: View { endPoint: .bottom ) ) - .navigationTitle(playlist.name) + .navigationTitle(playlistName) .navigationBarTitleDisplayMode(.inline) .toolbar { - ToolbarItemGroup(placement: .topBarTrailing) { - Button { - isAddingFiles = true - } label: { - Image(systemName: "plus") - } - } if #available(iOS 26.0, *) { ToolbarSpacer(.fixed, placement: .topBarTrailing) } ToolbarItemGroup(placement: .topBarTrailing) { Menu { Button { - editedPlaylistName = playlist.name + editedPlaylistName = playlistName isRenamingPlaylist = true } label: { Label("Playlists.Rename", systemImage: "pencil") } + Menu { + Button { + exportAsJSON() + } label: { + Label("Playlists.Export.JSON", systemImage: "doc.text") + } + Button { + exportAsM3U8() + } label: { + Label("Playlists.Export.M3U8", systemImage: "music.note.list") + } + } label: { + Label("Playlists.Export", systemImage: "square.and.arrow.up") + } } label: { Image(systemName: "ellipsis.circle") } } ToolbarItem(placement: .principal) { - Text(playlist.name) + Text(playlistName) .lineLimit(1) .truncationMode(.middle) .bold() @@ -159,64 +180,59 @@ struct PlaylistDetailView: View { .transition(.opacity.animation(.default.speed(0.2))) } } - .sheet(isPresented: $isAddingFiles) { - DocumentPicker( - allowedUTIs: [.audio, .image, .text, .pdf, .zip], - onDocumentPicked: { url in - addFileToPlaylist(url: url) - } - ) - .ignoresSafeArea(edges: [.bottom]) - } .alert("Playlists.Rename", isPresented: $isRenamingPlaylist) { TextField("Playlists.PlaylistName", text: $editedPlaylistName) Button("Shared.Cancel", role: .cancel) { } Button("Shared.Save") { let trimmed = editedPlaylistName.trimmingCharacters(in: .whitespacesAndNewlines) - if !trimmed.isEmpty { - playlist.name = trimmed + if !trimmed.isEmpty, var updated = playlist { + updated.name = trimmed + playlist = updated + PlaylistManager.save(updated, to: fileURL) } } } .onAppear { - resolveFiles() + loadPlaylist() } + .sheet(isPresented: $isExporting) { + if let exportURL { + ShareSheet(activityItems: [exportURL]) + .onDisappear { + try? FileManager.default.removeItem(at: exportURL) + self.exportURL = nil + } + } + } + } + + func loadPlaylist() { + playlist = PlaylistManager.load(from: fileURL) + resolveFiles() } func resolveFiles() { + guard let playlist else { + resolvedFiles = [] + return + } var resolved: [ResolvedPlaylistFile] = [] - for bookmark in playlist.sortedBookmarks { - if let file = bookmark.resolveFile() { - resolved.append(ResolvedPlaylistFile(bookmark: bookmark, file: file)) + for playlistFile in playlist.files { + if let fsFile = playlistFile.resolve(relativeTo: baseURL) { + resolved.append(ResolvedPlaylistFile(playlistFile: playlistFile, file: fsFile)) } } resolvedFiles = resolved } - func addFileToPlaylist(url: URL) { - let accessing = url.startAccessingSecurityScopedResource() - defer { - if accessing { url.stopAccessingSecurityScopedResource() } - } - do { - try playlist.addFile(url: url) - resolveFiles() - } catch { - debugPrint("Failed to add file to playlist: \(error.localizedDescription)") - } - } - func deleteItems(from section: [ResolvedPlaylistFile], at offsets: IndexSet) { + guard var updated = playlist else { return } for index in offsets { let item = section[index] - if let bookmarkIndex = playlist.fileBookmarks.firstIndex(where: { - $0.persistentModelID == item.bookmark.persistentModelID - }) { - let bookmark = playlist.fileBookmarks[bookmarkIndex] - playlist.fileBookmarks.remove(at: bookmarkIndex) - modelContext.delete(bookmark) - } + updated.files.removeAll { $0.relativePath == item.playlistFile.relativePath } } + playlist = updated + PlaylistManager.save(updated, to: fileURL) resolveFiles() } @@ -247,10 +263,32 @@ struct PlaylistDetailView: View { default: ListFileRow(file: .constant(file)) } } + + func exportAsJSON() { + guard let playlist else { return } + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + guard let data = try? encoder.encode(playlist) else { return } + let tempURL = FileManager.default.temporaryDirectory + .appendingPathComponent("\(playlist.name).json") + try? data.write(to: tempURL) + exportURL = tempURL + isExporting = true + } + + func exportAsM3U8() { + guard let playlist else { return } + let content = playlist.toM3U8() + let tempURL = FileManager.default.temporaryDirectory + .appendingPathComponent("\(playlist.name).m3u8") + try? content.write(to: tempURL, atomically: true, encoding: .utf8) + exportURL = tempURL + isExporting = true + } } struct ResolvedPlaylistFile: Identifiable { - let bookmark: PlaylistFileBookmark + let playlistFile: PlaylistFile let file: FSFile - var id: String { bookmark.order.description + file.path } + var id: String { playlistFile.relativePath + file.path } } diff --git a/Melodee/Views/Playlists/PlaylistRow.swift b/Melodee/Views/Playlists/PlaylistRow.swift deleted file mode 100644 index a812cbe..0000000 --- a/Melodee/Views/Playlists/PlaylistRow.swift +++ /dev/null @@ -1,95 +0,0 @@ -// -// PlaylistRow.swift -// Melodee -// -// Created by シン・ジャスティン on 2024/03/16. -// - -@preconcurrency import AVFoundation -import SwiftUI - -struct PlaylistRow: View { - - var playlist: Playlist - @State var thumbnail: UIImage? - - var body: some View { - HStack(alignment: .center, spacing: 16.0) { - if let thumbnail = thumbnail { - Image(uiImage: thumbnail) - .resizable() - .scaledToFill() - .frame(width: 44.0, height: 44.0) - .clipShape(RoundedRectangle(cornerRadius: 8.0)) - .overlay( - RoundedRectangle(cornerRadius: 8.0) - .stroke(.primary, lineWidth: 1/3) - .opacity(0.3) - ) - } else { - Image("Album.Generic") - .resizable() - .scaledToFill() - .frame(width: 44.0, height: 44.0) - .clipShape(RoundedRectangle(cornerRadius: 8.0)) - .overlay( - RoundedRectangle(cornerRadius: 8.0) - .stroke(.primary, lineWidth: 1/3) - .opacity(0.3) - ) - } - VStack(alignment: .leading, spacing: 2.0) { - Text(playlist.name) - .font(.body) - .lineLimit(1) - .truncationMode(.middle) - Text(songCountText()) - .font(.caption) - .lineLimit(1) - .foregroundStyle(.secondary) - } - } - .task { - await loadThumbnail() - } - } - - func songCountText() -> String { - let audioCount = playlist.sortedBookmarks.filter { bookmark in - FilesystemManager.fileType(forExtension: bookmark.fileExtension) == .audio - }.count - let totalCount = playlist.fileBookmarks.count - if totalCount == 0 { - return NSLocalizedString("Playlists.NoItems", comment: "") - } else if audioCount == totalCount { - return String(format: NSLocalizedString("Playlists.SongCount", comment: ""), audioCount) - } else { - return String(format: NSLocalizedString("Playlists.ItemCount", comment: ""), totalCount) - } - } - - func loadThumbnail() async { - guard let file = playlist.firstTaggableAudioFile() else { return } - let url = URL(fileURLWithPath: file.path) - do { - let asset = AVURLAsset(url: url) - let metadataList = try await asset.load(.metadata) - for item in metadataList { - switch item.commonKey { - case .commonKeyArtwork?: - if let data = try await item.load(.dataValue), - let image = UIImage(data: data), - let thumb = await image.byPreparingThumbnail( - ofSize: CGSize(width: 100.0, height: 100.0) - ) { - thumbnail = thumb - return - } - default: break - } - } - } catch { - debugPrint("Error loading playlist thumbnail: \(error.localizedDescription)") - } - } -} diff --git a/Melodee/Views/Playlists/PlaylistsView.swift b/Melodee/Views/Playlists/PlaylistsView.swift deleted file mode 100644 index 5390b82..0000000 --- a/Melodee/Views/Playlists/PlaylistsView.swift +++ /dev/null @@ -1,97 +0,0 @@ -// -// PlaylistsView.swift -// Melodee -// -// Created by シン・ジャスティン on 2024/03/16. -// - -import SwiftData -import SwiftUI - -struct PlaylistsView: View { - - @Environment(\.modelContext) private var modelContext - @Query(sort: \Playlist.name) private var playlists: [Playlist] - - @State var isCreatingPlaylist: Bool = false - @State var newPlaylistName: String = "" - - var body: some View { - NavigationStack { - List { - Section { - ForEach(playlists) { playlist in - NavigationLink(value: playlist.persistentModelID) { - PlaylistRow(playlist: playlist) - } - .listRowBackground(Color.clear) - } - .onDelete(perform: deletePlaylists) - } - } - .listStyle(.plain) - .scrollContentBackground(.hidden) - .background( - .linearGradient( - colors: [.backgroundGradientTop, .backgroundGradientBottom], - startPoint: .top, - endPoint: .bottom - ) - ) - .navigationTitle("ViewTitle.Playlists") - .navigationDestination(for: PersistentIdentifier.self) { playlistID in - if let playlist = playlists.first(where: { $0.persistentModelID == playlistID }) { - PlaylistDetailView(playlist: playlist) - } - } - .toolbar { - ToolbarItemGroup(placement: .topBarTrailing) { - Button { - newPlaylistName = "" - isCreatingPlaylist = true - } label: { - Image(systemName: "plus") - } - } - } - .overlay { - if playlists.isEmpty { - ContentUnavailableView { - Label("Playlists.Empty.Title", systemImage: "music.note.list") - } description: { - Text("Playlists.Empty.Description") - } actions: { - Button { - newPlaylistName = "" - isCreatingPlaylist = true - } label: { - Text("Playlists.CreatePlaylist") - } - .buttonStyle(.borderedProminent) - } - } - } - .alert("Playlists.CreatePlaylist", isPresented: $isCreatingPlaylist) { - TextField("Playlists.PlaylistName", text: $newPlaylistName) - Button("Shared.Cancel", role: .cancel) { } - Button("Shared.Create") { - createPlaylist() - } - } - .hasFileBrowserNavigationDestinations() - } - } - - func createPlaylist() { - let trimmedName = newPlaylistName.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmedName.isEmpty else { return } - let playlist = Playlist(name: trimmedName) - modelContext.insert(playlist) - } - - func deletePlaylists(at offsets: IndexSet) { - for index in offsets { - modelContext.delete(playlists[index]) - } - } -} diff --git a/Melodee/Views/Shared/ShareSheet.swift b/Melodee/Views/Shared/ShareSheet.swift new file mode 100644 index 0000000..871528e --- /dev/null +++ b/Melodee/Views/Shared/ShareSheet.swift @@ -0,0 +1,20 @@ +// +// ShareSheet.swift +// Melodee +// +// Created by シン・ジャスティン on 2024/03/16. +// + +import SwiftUI + +struct ShareSheet: UIViewControllerRepresentable { + let activityItems: [Any] + + func makeUIViewController(context: Context) -> UIActivityViewController { + UIActivityViewController(activityItems: activityItems, applicationActivities: nil) + } + + func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) { + // Not implemented + } +}