├── icon.png ├── Meruru.webp ├── Bundler.toml ├── Makefile ├── .gitignore ├── Package.resolved ├── README.md ├── LICENSE ├── Package.swift └── Sources ├── VLCPlayerView.swift ├── Settings.swift ├── Mirakurun.swift ├── App.swift └── AppViewModel.swift /icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/castaneai/Meruru/HEAD/icon.png -------------------------------------------------------------------------------- /Meruru.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/castaneai/Meruru/HEAD/Meruru.webp -------------------------------------------------------------------------------- /Bundler.toml: -------------------------------------------------------------------------------- 1 | format_version = 2 2 | 3 | [apps.Meruru] 4 | product = "Meruru" 5 | version = "0.1.0" 6 | identifier = "net.castaneai.Meruru" 7 | minimum_macos_version = "14" 8 | icon = "icon.png" 9 | 10 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | build: 2 | swift build -c release 3 | swift bundler bundle -o . -c release -u 4 | mkdir -p Meruru.app/Contents/Frameworks 5 | cp -r .build/release/VLCKit.framework Meruru.app/Contents/Frameworks/ 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /Meruru.app 2 | .DS_Store 3 | /.build 4 | /Packages 5 | /*.xcodeproj 6 | xcuserdata/ 7 | DerivedData/ 8 | .swiftpm/config/registries.json 9 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 10 | .netrc -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "originHash" : "b5355f6657d7aad4fd00b0939f10f0729c256684f7d0b4707881d2b3d6a65396", 3 | "pins" : [ 4 | { 5 | "identity" : "vlckit-spm", 6 | "kind" : "remoteSourceControl", 7 | "location" : "https://github.com/tylerjonesio/vlckit-spm", 8 | "state" : { 9 | "revision" : "e932bbd488872fdb74f6654d28c2f291eae03daf", 10 | "version" : "3.6.0" 11 | } 12 | } 13 | ], 14 | "version" : 3 15 | } 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Meruru 2 | 3 | A Simple [Mirakurun](https://github.com/Chinachu/Mirakurun)/[mirakc](https://github.com/mirakc/mirakc) Client for macOS. 4 | 5 | ![Meruru](./Meruru.webp) 6 | 7 | ## Requirements 8 | 9 | - macOS v14+ (Sonoma) 10 | 11 | ## Build 12 | 13 | You can build Meruru using [Swift Bundler](https://swiftbundler.dev/) in macOS environment. 14 | 15 | ```shell 16 | make build 17 | open Meruru.app 18 | ``` 19 | 20 | ## License 21 | 22 | [Apache License, Version 2.0](LICENSE) 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2019 castaneai 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | 15 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 6.0 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "Meruru", 8 | platforms: [ 9 | .macOS(.v14) 10 | ], 11 | dependencies: [ 12 | .package(url: "https://github.com/tylerjonesio/vlckit-spm", .upToNextMajor(from: "3.6.0")) 13 | ], 14 | targets: [ 15 | .executableTarget( 16 | name: "Meruru", 17 | dependencies: [ 18 | .product(name: "VLCKitSPM", package: "vlckit-spm") 19 | ] 20 | ) 21 | ] 22 | ) 23 | -------------------------------------------------------------------------------- /Sources/VLCPlayerView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import VLCKit 3 | 4 | struct VLCPlayerView: NSViewRepresentable { 5 | let mediaURL: URL? 6 | @Binding var volume: Double 7 | 8 | class Coordinator { 9 | let mediaPlayer = VLCMediaPlayer() 10 | var currentMediaURL: URL? 11 | } 12 | 13 | func makeCoordinator() -> Coordinator { 14 | return Coordinator() 15 | } 16 | 17 | func makeNSView(context: Context) -> VLCVideoView { 18 | let videoView = VLCVideoView() 19 | videoView.autoresizingMask = [.width, .height] 20 | videoView.fillScreen = true 21 | context.coordinator.mediaPlayer.drawable = videoView 22 | return videoView 23 | } 24 | 25 | func updateNSView(_ nsView: VLCVideoView, context: Context) { 26 | let coordinator = context.coordinator 27 | coordinator.mediaPlayer.audio?.volume = Int32(volume * 100) 28 | if coordinator.currentMediaURL != mediaURL { 29 | coordinator.currentMediaURL = mediaURL 30 | playVideo(mediaPlayer: coordinator.mediaPlayer) 31 | } 32 | } 33 | 34 | func playVideo(mediaPlayer: VLCMediaPlayer) { 35 | print("playVideo, \(String(describing: mediaURL))") 36 | if let mediaURL = mediaURL { 37 | let media = VLCMedia(url: mediaURL) 38 | mediaPlayer.media = media 39 | mediaPlayer.play() 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Sources/Settings.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct SettingsView: View { 4 | @ObservedObject var viewModel: AppViewModel 5 | @State var checkConnectionStatus: String = "" 6 | @Environment(\.dismiss) private var dismiss 7 | 8 | var body: some View { 9 | VStack { 10 | Form { 11 | TextField("Mirakurun URL", text: $viewModel.mirakurunURL) 12 | Button("Check connection") { Task { await checkConnection() } } 13 | Text(checkConnectionStatus) 14 | } 15 | Spacer() 16 | HStack { 17 | Spacer() 18 | Button("OK") { 19 | self.dismiss() 20 | } 21 | .keyboardShortcut(.defaultAction) 22 | }.padding() 23 | } 24 | .navigationTitle("Meruru Settings") 25 | .padding() 26 | .frame(width: 500, height: 150) 27 | } 28 | 29 | private func checkConnection() async { 30 | do { 31 | let version = try await Mirakurun(baseURL: viewModel.mirakurunURL).fetchVersion() 32 | checkConnectionStatus = "OK (Version: \(version.current))" 33 | } catch { 34 | if let urlError = error as? URLError { 35 | checkConnectionStatus = "Fail: \(urlError.localizedDescription)" 36 | } else { 37 | checkConnectionStatus = "Fail: \(error)" 38 | } 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Sources/Mirakurun.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct Version: Codable, Sendable { 4 | let current: String 5 | let latest: String 6 | } 7 | 8 | struct Channel: Codable, Sendable, Identifiable, Hashable { 9 | var id: String { self.channel } 10 | let type: String 11 | let channel: String 12 | let name: String 13 | let services: [Service] 14 | } 15 | 16 | struct Service: Codable, Sendable, Identifiable, Hashable { 17 | let id: Int 18 | let serviceId: Int 19 | let name: String 20 | } 21 | 22 | struct Program: Codable, Sendable, Identifiable, Hashable { 23 | let id: Int 24 | let name: String 25 | let serviceId: Int 26 | let startAt: Int64 27 | let duration: Int64 28 | 29 | var startedAt: Date { 30 | Date(timeIntervalSince1970: TimeInterval(self.startAt) / 1000) 31 | } 32 | 33 | var endedAt: Date { 34 | Date(timeIntervalSince1970: TimeInterval(self.startAt + self.duration) / 1000) 35 | } 36 | 37 | func isOnAir(now: Date) -> Bool { 38 | return now >= self.startedAt && now < self.endedAt 39 | } 40 | } 41 | 42 | final class Mirakurun: Sendable { 43 | private let baseURL: String 44 | init(baseURL: String) { 45 | self.baseURL = baseURL 46 | } 47 | 48 | func fetchVersion() async throws -> Version { 49 | let url = URL(string: "\(self.baseURL)/api/version")! 50 | let (data, _) = try await URLSession.shared.data(from: url) 51 | return try JSONDecoder().decode(Version.self, from: data) 52 | } 53 | 54 | func fetchChannels() async throws -> [Channel] { 55 | let url = URL(string: "\(self.baseURL)/api/channels")! 56 | let (data, _) = try await URLSession.shared.data(from: url) 57 | return try JSONDecoder().decode([Channel].self, from: data) 58 | } 59 | 60 | func fetchPrograms(channel: Channel) async throws -> [Program] { 61 | if let serviceID = channel.services.first?.id { 62 | let url = URL(string: "\(self.baseURL)/api/services/\(serviceID)/programs")! 63 | let (data, _) = try await URLSession.shared.data(from: url) 64 | return try JSONDecoder().decode([Program].self, from: data) 65 | } 66 | return [] 67 | } 68 | 69 | func getStreamURL(channel: Channel) -> URL { 70 | URL(string: "\(self.baseURL)/api/channels/\(channel.type)/\(channel.channel)/stream")! 71 | } 72 | 73 | func fetchNowOnAirProgram(channel: Channel) async throws -> Program? { 74 | try await self.fetchPrograms(channel: channel).first(where: { 75 | $0.isOnAir(now: Date.now) 76 | }) 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /Sources/App.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import VLCKit 3 | 4 | @main 5 | struct MyApp: App { 6 | @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate 7 | 8 | @StateObject private var viewModel = AppViewModel() 9 | @Environment(\.openSettings) private var openSettings 10 | 11 | var body: some Scene { 12 | WindowGroup { 13 | AppView(viewModel: viewModel) 14 | .task { onAppStart() } 15 | .navigationTitle(windowTitle()) 16 | } 17 | Settings { 18 | SettingsView(viewModel: viewModel) 19 | } 20 | } 21 | 22 | private func windowTitle() -> String { 23 | if let onAir = viewModel.nowOnAirProgramTitle { 24 | return "\(onAir) | Meruru" 25 | } 26 | return "Meruru" 27 | } 28 | 29 | private func onAppStart() { 30 | if viewModel.mirakurunURL == "" { 31 | openSettings() 32 | } else { 33 | viewModel.initMirakurun() 34 | } 35 | } 36 | } 37 | 38 | struct AppView: View { 39 | @ObservedObject var viewModel: AppViewModel 40 | 41 | var body: some View { 42 | VStack { 43 | VLCPlayerView(mediaURL: viewModel.getSelectedChannelStreamURL(), volume: $viewModel.volume) 44 | .background(Color.black) 45 | HStack { 46 | HStack { 47 | Image(systemName: "speaker.wave.1") 48 | .foregroundColor(.secondary) 49 | Slider(value: $viewModel.volume, in: 0 ... 1) 50 | .frame(width: 100) 51 | } 52 | Picker("channel", selection: $viewModel.selectedChannel) { 53 | ForEach(viewModel.channels) { item in 54 | Text(item.name).tag(item as Channel?) 55 | } 56 | } 57 | .pickerStyle(MenuPickerStyle()) 58 | SettingsLink { 59 | Label("Settings", systemImage: "gear") 60 | } 61 | Button("Connect") { 62 | viewModel.initMirakurun() 63 | } 64 | }.padding(.horizontal, 10).padding(.vertical, 6) 65 | Text(viewModel.statusMessage ?? "") 66 | .frame(maxWidth: .infinity, alignment: .leading) 67 | .padding(.horizontal, 10).padding(.vertical, 6) 68 | } 69 | } 70 | } 71 | 72 | class AppDelegate: NSObject, NSApplicationDelegate { 73 | func applicationDidFinishLaunching(_ notification: Notification) { 74 | NSApplication.shared.setActivationPolicy(.regular) 75 | NSApplication.shared.activate(ignoringOtherApps: true) 76 | } 77 | 78 | func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { 79 | return true 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /Sources/AppViewModel.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | @MainActor 4 | class AppViewModel: ObservableObject { 5 | @AppStorage("mirakurunUrl") var mirakurunURL: String = "http://mirakurun:40772" 6 | @AppStorage("lastChannelId") var lastChannelID: String = "" 7 | @AppStorage("volume") var volume: Double = 1.0 8 | @Published var selectedChannel: Channel? { 9 | didSet { 10 | onChannelChanged(channel: selectedChannel) 11 | } 12 | } 13 | 14 | @Published var channels: [Channel] = [] 15 | @Published var nowOnAirProgramTitle: String? 16 | @Published var statusMessage: String? 17 | 18 | private var mirakurun: Mirakurun? 19 | private var updateTimer: Timer? 20 | private var currentPrograms: [Program] = [] 21 | 22 | func initMirakurun() { 23 | if mirakurunURL == "" { 24 | statusMessage = "mirakurun URL is missing" 25 | return 26 | } 27 | mirakurun = Mirakurun(baseURL: mirakurunURL) 28 | Task { 29 | await fetchChannels() 30 | if let channel = channels.first(where: { $0.id == lastChannelID }) { 31 | selectedChannel = channel 32 | } else if channels.count > 0 { 33 | selectedChannel = channels.first 34 | } 35 | } 36 | } 37 | 38 | private func fetchChannels() async { 39 | guard let mirakurun = mirakurun else { return } 40 | 41 | do { 42 | statusMessage = "fetching channels..." 43 | channels = try await mirakurun.fetchChannels() 44 | statusMessage = "channel fetch OK" 45 | 46 | } catch { 47 | if let urlError = error as? URLError { 48 | statusMessage = "failed to fetch channels: \(urlError.localizedDescription)" 49 | } else { 50 | statusMessage = "failed to fetch channels: \(error)" 51 | } 52 | } 53 | } 54 | 55 | private func onChannelChanged(channel: Channel?) { 56 | guard let channel = channel else { 57 | stopUpdateTimer() 58 | return 59 | } 60 | 61 | lastChannelID = channel.id 62 | statusMessage = "" 63 | Task { 64 | await updateNowOnAirProgram() 65 | await setupUpdateTimer() 66 | } 67 | } 68 | 69 | func getSelectedChannelStreamURL() -> URL? { 70 | guard let mirakurun = mirakurun, let selectedChannel = selectedChannel else { return nil } 71 | 72 | return mirakurun.getStreamURL(channel: selectedChannel) 73 | } 74 | 75 | func updateNowOnAirProgram() async { 76 | guard let mirakurun = mirakurun, let selectedChannel = selectedChannel else { return } 77 | 78 | do { 79 | currentPrograms = try await mirakurun.fetchPrograms(channel: selectedChannel) 80 | if let program = currentPrograms.first(where: { $0.isOnAir(now: Date.now) }) { 81 | nowOnAirProgramTitle = program.name 82 | } 83 | } catch { 84 | statusMessage = "failed to fetch now on air: \(error)" 85 | } 86 | } 87 | 88 | private func setupUpdateTimer() async { 89 | stopUpdateTimer() 90 | 91 | guard !currentPrograms.isEmpty else { return } 92 | 93 | let now = Date.now 94 | var nextUpdateTime: Date? 95 | 96 | for program in currentPrograms { 97 | if program.startedAt > now { 98 | if nextUpdateTime == nil || program.startedAt < nextUpdateTime! { 99 | nextUpdateTime = program.startedAt 100 | } 101 | } else if program.isOnAir(now: now) { 102 | if nextUpdateTime == nil || program.endedAt < nextUpdateTime! { 103 | nextUpdateTime = program.endedAt 104 | } 105 | } 106 | } 107 | 108 | guard let updateTime = nextUpdateTime else { return } 109 | 110 | let timeInterval = updateTime.timeIntervalSince(now) 111 | if timeInterval > 0 { 112 | updateTimer = Timer.scheduledTimer(withTimeInterval: timeInterval, repeats: false) { [weak self] _ in 113 | Task { @MainActor in 114 | await self?.updateNowOnAirProgram() 115 | await self?.setupUpdateTimer() 116 | } 117 | } 118 | } 119 | } 120 | 121 | private func stopUpdateTimer() { 122 | updateTimer?.invalidate() 123 | updateTimer = nil 124 | } 125 | } 126 | --------------------------------------------------------------------------------