├── .github
├── chrome.svg
├── chromium.svg
├── example.gif
├── firefox.svg
├── opera.svg
├── vivaldi.svg
├── yandex.svg
└── ym.svg
├── .gitignore
├── LICENSE
├── README-ru.md
├── README.md
├── chrome_extension
├── _locales
│ ├── en
│ │ └── messages.json
│ └── ru
│ │ └── messages.json
├── icons
│ ├── 48.png
│ └── 96.png
├── manifest.json
├── ymc.js
├── ymc_injection.js
└── ymc_injector.js
├── firefox_extension
├── .gitignore
├── _locales
│ ├── en
│ │ └── messages.json
│ └── ru
│ │ └── messages.json
├── icons
│ ├── 48.png
│ └── 96.png
├── manifest.json
├── ymc.js
├── ymc_injection.js
└── ymc_injector.js
└── native
├── .gitignore
├── Cartfile
├── Cartfile.resolved
├── YMC.xcodeproj
├── project.pbxproj
└── project.xcworkspace
│ ├── contents.xcworkspacedata
│ └── xcshareddata
│ └── IDEWorkspaceChecks.plist
└── YMC
├── Application
├── AppDelegate
│ ├── AppDelegate+MediaKeyTap.swift
│ ├── AppDelegate+Messaging.swift
│ ├── AppDelegate+Notifications.swift
│ ├── AppDelegate+Permissions.swift
│ └── AppDelegate.swift
└── PlayerViewController.swift
├── Assets.xcassets
├── AppIcon.appiconset
│ ├── 1024.png
│ ├── 128.png
│ ├── 16.png
│ ├── 256-1.png
│ ├── 256.png
│ ├── 32-1.png
│ ├── 32.png
│ ├── 512-1.png
│ ├── 512.png
│ ├── 64.png
│ └── Contents.json
├── Contents.json
├── forward.imageset
│ ├── Contents.json
│ └── fast-forward.pdf
├── heart.imageset
│ ├── Contents.json
│ └── heart.pdf
├── link.imageset
│ ├── Contents.json
│ └── share.pdf
├── logo.imageset
│ ├── Contents.json
│ └── logo.pdf
├── logo_large.imageset
│ ├── Contents.json
│ └── logo_large.pdf
├── pause.imageset
│ ├── Contents.json
│ └── pause.pdf
├── play.imageset
│ ├── Contents.json
│ └── play.pdf
├── refresh.imageset
│ ├── Contents.json
│ └── refresh.pdf
├── rewind.imageset
│ ├── Contents.json
│ └── rewind.pdf
└── settings.imageset
│ ├── Contents.json
│ └── settings.pdf
├── Base.lproj
└── Main.storyboard
├── Classes
├── Logger.swift
├── Manifest.swift
├── Messaging.swift
└── Player.swift
├── Info.plist
├── Vendor
└── BinUtils.swift
└── YMC.entitlements
/.github/chrome.svg:
--------------------------------------------------------------------------------
1 |
2 |
106 |
--------------------------------------------------------------------------------
/.github/chromium.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
278 |
--------------------------------------------------------------------------------
/.github/example.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Ty3uK/YMC/a4ae8f63b5a809f3b83860972f0bef49bf28118f/.github/example.gif
--------------------------------------------------------------------------------
/.github/firefox.svg:
--------------------------------------------------------------------------------
1 |
139 |
--------------------------------------------------------------------------------
/.github/opera.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.github/vivaldi.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.github/yandex.svg:
--------------------------------------------------------------------------------
1 |
2 |
77 |
--------------------------------------------------------------------------------
/.github/ym.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | .cache
3 | dist
4 | node_modules
5 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2019 Maksim Karelov
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README-ru.md:
--------------------------------------------------------------------------------
1 |
2 | English version
3 |
4 |
5 |
6 |
7 |
8 | Yandex.Music Control
9 |
10 |
11 |
12 | Управляйте Яндекс.Музыкой из любого окна macOS
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 | Возможности •
25 | TODO •
26 | Горячие клавиши •
27 | Установка •
28 | Отладка •
29 | Сторонние библиотеки •
30 | Лицензия
31 |
32 |
33 |
34 |
35 |
36 |
37 | ## Возможности
38 |
39 | - Управляйте воспроизведением из любого окна или экрана при помощи виджета в верхней панели
40 | - Управляйте воспроизведением при помощи стандартных медиа-клавиш Мак-устройства
41 | - Получайте информацию о текущей композиции при помощи уведомления
42 | - Делитесь ссылкой на текущую композицию через системное меню "Поделиться"
43 |
44 | ## TODO
45 |
46 | - [ ] Обрабатывать нажатия на кнопки гарнитур
47 | - [ ] Обрабатывать другие домены (не только music.yandex.ru)
48 |
49 | ## Горячие клавиши
50 |
51 | | **Функции** | **Горячие клавиши** |
52 | |-----------------------------|---------------------------------------------------------|
53 | | **Play** | Воспроизведение/Пауза |
54 | | **Next** | Включить следующую композицию |
55 | | **Prev** | Включить предыдущую композицию |
56 | | **⌘** + **Play** | Показать информацию о текущей композиции |
57 | | **⌘** + **Next** | Поставить/Снять отмеку "Нравится" с текущей композиции |
58 | | **⌘** + **Prev** | Скопировать ссылки на текущую композицию |
59 |
60 | ## Установка
61 |
62 | - Скачайте и установите расширение для Вашего любимого браузера со [страницы релизов](https://github.com/Ty3uK/YMC/releases)
63 | - Скачайте приложение со [страницы релизов](https://github.com/Ty3uK/YMC/releases) и скопируйте в папку `Программы`
64 | - Запустите приложение и дождитесь сообщения `Manifest updated`, нажмите `Got it`
65 | - Приложение запустится автоматически при посещении `music.yandex.ru`
66 |
67 | Если Вы хотите управлять воспроизведением при помощи стандартных медиа-клавиш - включите браузер в настройках универсального доступа (`Системные настройки → Защита и безопасность → Универсальный доступ`) и обновите вкладку яндекс.музыки в браузере.
68 |
69 | #### Note
70 |
71 | В случае ошибки `The application can't be opened` или `App is damaged and can't be opened` запустите следующую команду в терминале:
72 |
73 | ```bash
74 | sudo spctl --master-disable
75 | ```
76 |
77 | ## Отладка
78 |
79 | При включенной настройке `Enable debugging` приложение записывает лог в файл `/tmp/ymc.log`. Пожалуйста, прикладывайте файл лога при создании ишью.
80 |
81 | ## Сторонние библиотеки
82 |
83 | - [RxSwift](https://github.com/ReactiveX/RxSwift)
84 | - [SwiftyBeaver](https://github.com/SwiftyBeaver/SwiftyBeaver)
85 | - [MediaKeyTap](https://github.com/nhurden/MediaKeyTap) ([fork](https://github.com/Ty3uK/MediaKeyTap))
86 | - [Files](https://github.com/JohnSundell/Files)
87 | - [BinUtils](https://github.com/nst/BinUtils)
88 |
89 | ## Лицензия
90 |
91 | [MIT](LICENSE)
92 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 | Русская версия
3 |
4 |
5 |
6 |
7 |
8 | Yandex.Music Control
9 |
10 |
11 |
12 | Manage Yandex.Music from any macOS window
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 | Features •
25 | TODO •
26 | Hotkeys •
27 | Install •
28 | Debug •
29 | External libraries •
30 | License
31 |
32 |
33 |
34 |
35 |
36 |
37 | ## Features
38 |
39 | - Control playback from any window or screen using the widget in the top panel
40 | - Control playback with Mac native media keys
41 | - Get current track info with notification
42 | - Share link to track through native sharing menu
43 |
44 | ## TODO
45 |
46 | - [ ] Handle headphones media keys
47 | - [ ] Handle other domains (not only music.yandex.ru)
48 |
49 | ## Hotkeys
50 |
51 | | **Function** | **Hotkey** |
52 | |-----------------------------|----------------------------|
53 | | **Play** | Play/Pause track |
54 | | **Next** | Switch to next track |
55 | | **Prev** | Switch to previous track |
56 | | **⌘** + **Play** | Show current track info |
57 | | **⌘** + **Next** | Like/Unlike current track |
58 | | **⌘** + **Prev** | Copy current track link |
59 |
60 | ## Install
61 |
62 | - Download and install extension for your favorite browser from [release page](https://github.com/Ty3uK/YMC/releases)
63 | - Download application from [release page](https://github.com/Ty3uK/YMC/releases) and copy to `Applications` folder
64 | - Run application and wait for message `Manifest updated`, click `Got it`
65 | - When you visit `music.yandex.ru` application will be started automatically
66 |
67 | If you want to control playback with native media keys - enable your browser in accessibility settings (`System Preferences → Security & Privacy → Accessibility`) and reload yandex music tab in browser
68 |
69 | #### Note
70 |
71 | If you get errors like `The application can't be opened` or `App is damaged and can't be opened` run this command in terminal:
72 |
73 | ```bash
74 | sudo spctl --master-disable
75 | ```
76 |
77 | ## Debug
78 |
79 | When `Enable debugging` checkbox is checked, application will write log file to `/tmp/ymc.log`. Attach log file in issue, please.
80 |
81 | ## External libraries
82 |
83 | - [RxSwift](https://github.com/ReactiveX/RxSwift)
84 | - [SwiftyBeaver](https://github.com/SwiftyBeaver/SwiftyBeaver)
85 | - [MediaKeyTap](https://github.com/nhurden/MediaKeyTap) ([fork](https://github.com/Ty3uK/MediaKeyTap))
86 | - [Files](https://github.com/JohnSundell/Files)
87 | - [BinUtils](https://github.com/nst/BinUtils)
88 |
89 | ## License
90 |
91 | [MIT](LICENSE)
92 |
--------------------------------------------------------------------------------
/chrome_extension/_locales/en/messages.json:
--------------------------------------------------------------------------------
1 | {
2 | "extensionName": {
3 | "message": "Yandex.Music Control"
4 | },
5 | "extensionDescription": {
6 | "message": "Control Yandex.Music from any screen in MacOS"
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/chrome_extension/_locales/ru/messages.json:
--------------------------------------------------------------------------------
1 | {
2 | "extensionName": {
3 | "message": "Yandex.Music Control"
4 | },
5 | "extensionDescription": {
6 | "message": "Управляйте Яндекс.Музыкой с любого экрана в MacOS"
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/chrome_extension/icons/48.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Ty3uK/YMC/a4ae8f63b5a809f3b83860972f0bef49bf28118f/chrome_extension/icons/48.png
--------------------------------------------------------------------------------
/chrome_extension/icons/96.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Ty3uK/YMC/a4ae8f63b5a809f3b83860972f0bef49bf28118f/chrome_extension/icons/96.png
--------------------------------------------------------------------------------
/chrome_extension/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "manifest_version": 2,
3 | "name": "__MSG_extensionName__",
4 | "description": "__MSG_extensionDescription__",
5 | "version": "1.1.0",
6 |
7 | "default_locale": "en",
8 |
9 | "icons": {
10 | "48": "icons/48.png",
11 | "96": "icons/96.png"
12 | },
13 |
14 | "web_accessible_resources": [
15 | "ymc_injection.js"
16 | ],
17 |
18 | "background": {
19 | "scripts": ["ymc.js"]
20 | },
21 |
22 | "content_scripts": [
23 | {
24 | "matches": ["*://music.yandex.ru/*", "*://radio.yandex.ru/*"],
25 | "js": ["ymc_injector.js"]
26 | }
27 | ],
28 |
29 | "permissions": [
30 | "*://music.yandex.ru/*",
31 | "*://radio.yandex.ru/*",
32 | "tabs",
33 | "nativeMessaging",
34 | "notifications",
35 | "clipboardWrite"
36 | ]
37 | }
38 |
--------------------------------------------------------------------------------
/chrome_extension/ymc.js:
--------------------------------------------------------------------------------
1 | (function() {
2 | let page;
3 | let app;
4 |
5 | const messageQueue = [];
6 |
7 | async function onConnect(port) {
8 | let tabs = (await queryTabs({ url: '*://*.yandex.ru/*' })) || [];
9 |
10 | tabs = tabs.filter(tab => tab.url.indexOf('music.') !== -1 || tab.url.indexOf('radio.') !== -1);
11 |
12 | if (tabs.length > 1) {
13 | return;
14 | }
15 |
16 | page = port;
17 | app = chrome.runtime.connectNative('ymc');
18 |
19 | port.onMessage.addListener(message => {
20 | messageQueue.push(message);
21 |
22 | const messageFromQueue = messageQueue.shift();
23 |
24 | if (messageFromQueue) {
25 | app.postMessage(message);
26 | }
27 | });
28 |
29 | port.onDisconnect.addListener(() => app.disconnect());
30 |
31 | app.onMessage.addListener(message => page.postMessage(message));
32 | }
33 |
34 | chrome.runtime.onConnect.addListener(onConnect);
35 |
36 | async function queryTabs(options) {
37 | return new Promise(resolve => chrome.tabs.query(options, resolve));
38 | }
39 | })();
40 |
--------------------------------------------------------------------------------
/chrome_extension/ymc_injection.js:
--------------------------------------------------------------------------------
1 | (function() {
2 | const scriptElement = document.querySelector('script[data-ymc-id]');
3 | const id = scriptElement && scriptElement.getAttribute('data-ymc-id');
4 |
5 | if (!id) {
6 | return;
7 | }
8 |
9 | let isPlaying;
10 |
11 | externalAPI.on(externalAPI.EVENT_READY, sendCurrentTrack);
12 |
13 | externalAPI.on(externalAPI.EVENT_TRACK, sendCurrentTrack);
14 |
15 | externalAPI.on(externalAPI.EVENT_CONTROLS, () => {
16 | postMessage('CONTROLS', externalAPI.getControls());
17 | postMessage('TOGGLE_LIKE', { state: getLikedState() });
18 | });
19 |
20 | externalAPI.on(externalAPI.EVENT_STATE, () => {
21 | const playingState = externalAPI.isPlaying();
22 |
23 | if (isPlaying !== playingState) {
24 | isPlaying = playingState;
25 | postMessage('PLAYING', { state: isPlaying });
26 | }
27 | });
28 |
29 | window.addEventListener('message', event => {
30 | if (event.source !== window || !(event.data.type && event.data.type === 'YMC_EXT')) {
31 | return;
32 | }
33 |
34 | const message = event.data.data;
35 | const type = message.type;
36 |
37 | if (type === 'PLAY_PAUSE') {
38 | externalAPI.togglePause();
39 | } else if (type === 'PREV') {
40 | externalAPI.prev();
41 | } else if (type === 'NEXT') {
42 | externalAPI.next();
43 | } else if (type === 'TOGGLE_LIKE') {
44 | externalAPI.toggleLike().then(success => {
45 | if (!success) {
46 | return;
47 | }
48 |
49 | postMessage(type, { state: getLikedState() });
50 | });
51 | } else if (type === 'REFRESH') {
52 | postMessage('PLAYER_STATE', {
53 | currentTrack: getTrackInfo(),
54 | controls: externalAPI.getControls(),
55 | isPlaying: externalAPI.isPlaying()
56 | });
57 | }
58 | });
59 |
60 | function sendCurrentTrack() {
61 | postMessage('CURRENT_TRACK', getTrackInfo());
62 | }
63 |
64 | function getTrackInfo() {
65 | const trackInfo = externalAPI.getCurrentTrack();
66 |
67 | if (!trackInfo) {
68 | return {};
69 | }
70 |
71 | return {
72 | title: trackInfo.title,
73 | artist: trackInfo.artists.map(it => it.title).join(', '),
74 | cover: `https://${trackInfo.cover.slice(0, -3)}/400x400`,
75 | liked: trackInfo.liked,
76 | link: `${location.origin}${trackInfo.link}`
77 | }
78 | }
79 |
80 | function getLikedState() {
81 | const trackInfo = externalAPI.getCurrentTrack();
82 | return !!(trackInfo && trackInfo.liked);
83 | }
84 |
85 | function postMessage(type, data) {
86 | window.postMessage({ type: 'YMC_PAGE', data: { type: type, data: data }}, '*');
87 | }
88 | })();
89 |
--------------------------------------------------------------------------------
/chrome_extension/ymc_injector.js:
--------------------------------------------------------------------------------
1 | (function() {
2 | const id = chrome.runtime.id;
3 | const url = chrome.runtime.getURL('ymc_injection.js');
4 |
5 | let script = document.querySelector(`script[data-ymc-id='${id}']`);
6 |
7 | if (script) {
8 | return;
9 | }
10 |
11 | script = document.createElement('script');
12 | script.src = url;
13 | script.setAttribute('data-ymc-id', id);
14 |
15 | document.head.appendChild(script);
16 |
17 | const port = chrome.runtime.connect({ name: 'ymc' });
18 |
19 | port.onMessage.addListener(message => {
20 | window.postMessage({ type: 'YMC_EXT', data: message }, '*');
21 | });
22 |
23 | window.addEventListener('message', event => {
24 | if (event.source !== window || !(event.data.type && event.data.type === 'YMC_PAGE')) {
25 | return;
26 | }
27 |
28 | port.postMessage(event.data.data);
29 | });
30 | })();
31 |
--------------------------------------------------------------------------------
/firefox_extension/.gitignore:
--------------------------------------------------------------------------------
1 | .web-extension-id
2 | web-ext-artifacts
3 |
4 |
--------------------------------------------------------------------------------
/firefox_extension/_locales/en/messages.json:
--------------------------------------------------------------------------------
1 | {
2 | "extensionName": {
3 | "message": "Yandex.Music Control"
4 | },
5 | "extensionDescription": {
6 | "message": "Control Yandex.Music from any screen in MacOS"
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/firefox_extension/_locales/ru/messages.json:
--------------------------------------------------------------------------------
1 | {
2 | "extensionName": {
3 | "message": "Yandex.Music Control"
4 | },
5 | "extensionDescription": {
6 | "message": "Управляйте Яндекс.Музыкой с любого экрана в MacOS"
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/firefox_extension/icons/48.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Ty3uK/YMC/a4ae8f63b5a809f3b83860972f0bef49bf28118f/firefox_extension/icons/48.png
--------------------------------------------------------------------------------
/firefox_extension/icons/96.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Ty3uK/YMC/a4ae8f63b5a809f3b83860972f0bef49bf28118f/firefox_extension/icons/96.png
--------------------------------------------------------------------------------
/firefox_extension/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "manifest_version": 2,
3 | "name": "__MSG_extensionName__",
4 | "description": "__MSG_extensionDescription__",
5 | "version": "1.1.0",
6 |
7 | "default_locale": "en",
8 |
9 | "icons": {
10 | "48": "icons/48.png",
11 | "96": "icons/96.png"
12 | },
13 |
14 | "applications": {
15 | "gecko": {
16 | "id": "ymc@karelov.info",
17 | "strict_min_version": "54.0"
18 | }
19 | },
20 |
21 | "web_accessible_resources": [
22 | "ymc_injection.js"
23 | ],
24 |
25 | "background": {
26 | "scripts": ["ymc.js"]
27 | },
28 |
29 | "content_scripts": [
30 | {
31 | "matches": ["*://music.yandex.ru/*", "*://radio.yandex.ru/*"],
32 | "js": ["ymc_injector.js"]
33 | }
34 | ],
35 |
36 | "permissions": [
37 | "*://music.yandex.ru/*",
38 | "*://radio.yandex.ru/*",
39 | "tabs",
40 | "nativeMessaging"
41 | ]
42 | }
43 |
--------------------------------------------------------------------------------
/firefox_extension/ymc.js:
--------------------------------------------------------------------------------
1 | (function() {
2 | let page;
3 | let app;
4 |
5 | const messageQueue = [];
6 |
7 | async function onConnect(port) {
8 | let tabs = (await browser.tabs.query({ url: '*://*.yandex.ru/*' })) || [];
9 |
10 | tabs = tabs.filter(tab => tab.url.indexOf('music.') !== -1 || tab.url.indexOf('radio.') !== -1)
11 |
12 | if (tabs.length > 1) {
13 | return;
14 | }
15 |
16 | page = port;
17 | app = browser.runtime.connectNative('ymc');
18 |
19 | port.onMessage.addListener(message => {
20 | messageQueue.push(message);
21 |
22 | const messageFromQueue = messageQueue.shift();
23 |
24 | if (messageFromQueue) {
25 | app.postMessage(message);
26 | }
27 | });
28 |
29 | port.onDisconnect.addListener(() => app.disconnect());
30 |
31 | app.onMessage.addListener(message => page.postMessage(message));
32 | }
33 |
34 | browser.runtime.onConnect.addListener(onConnect);
35 | })();
36 |
--------------------------------------------------------------------------------
/firefox_extension/ymc_injection.js:
--------------------------------------------------------------------------------
1 | (function() {
2 | const scriptElement = document.querySelector('script[data-ymc-id]');
3 | const id = scriptElement && scriptElement.getAttribute('data-ymc-id');
4 |
5 | if (!id) {
6 | return;
7 | }
8 |
9 | let isPlaying;
10 |
11 | externalAPI.on(externalAPI.EVENT_READY, sendCurrentTrack);
12 |
13 | externalAPI.on(externalAPI.EVENT_TRACK, sendCurrentTrack);
14 |
15 | externalAPI.on(externalAPI.EVENT_CONTROLS, () => {
16 | postMessage('CONTROLS', externalAPI.getControls());
17 | postMessage('TOGGLE_LIKE', { state: getLikedState() });
18 | });
19 |
20 | externalAPI.on(externalAPI.EVENT_STATE, () => {
21 | const playingState = externalAPI.isPlaying();
22 |
23 | if (isPlaying !== playingState) {
24 | isPlaying = playingState;
25 | postMessage('PLAYING', { state: isPlaying });
26 | }
27 | });
28 |
29 | window.addEventListener('message', event => {
30 | if (event.source !== window || !(event.data.type && event.data.type === 'YMC_EXT')) {
31 | return;
32 | }
33 |
34 | const message = event.data.data;
35 | const type = message.type;
36 |
37 | if (type === 'PLAY_PAUSE') {
38 | externalAPI.togglePause();
39 | } else if (type === 'PREV') {
40 | externalAPI.prev();
41 | } else if (type === 'NEXT') {
42 | externalAPI.next();
43 | } else if (type === 'TOGGLE_LIKE') {
44 | externalAPI.toggleLike().then(success => {
45 | if (!success) {
46 | return;
47 | }
48 |
49 | postMessage(type, { state: getLikedState() });
50 | });
51 | } else if (type === 'REFRESH') {
52 | postMessage('PLAYER_STATE', {
53 | currentTrack: getTrackInfo(),
54 | controls: externalAPI.getControls(),
55 | isPlaying: externalAPI.isPlaying()
56 | });
57 | }
58 | });
59 |
60 | function sendCurrentTrack() {
61 | postMessage('CURRENT_TRACK', getTrackInfo());
62 | }
63 |
64 | function getTrackInfo() {
65 | const trackInfo = externalAPI.getCurrentTrack();
66 |
67 | if (!trackInfo) {
68 | return {};
69 | }
70 |
71 | return {
72 | title: trackInfo.title,
73 | artist: trackInfo.artists.map(it => it.title).join(', '),
74 | cover: `https://${trackInfo.cover.slice(0, -3)}/400x400`,
75 | liked: trackInfo.liked,
76 | link: `${location.origin}${trackInfo.link}`
77 | }
78 | }
79 |
80 | function getLikedState() {
81 | const trackInfo = externalAPI.getCurrentTrack();
82 | return !!(trackInfo && trackInfo.liked);
83 | }
84 |
85 | function postMessage(type, data) {
86 | window.postMessage({ type: 'YMC_PAGE', data: { type: type, data: data }}, '*');
87 | }
88 | })();
89 |
--------------------------------------------------------------------------------
/firefox_extension/ymc_injector.js:
--------------------------------------------------------------------------------
1 | (function() {
2 | const id = browser.runtime.id;
3 | const url = browser.runtime.getURL('ymc_injection.js');
4 |
5 | let script = document.querySelector(`script[data-ymc-id='${id}']`);
6 |
7 | if (script) {
8 | return;
9 | }
10 |
11 | script = document.createElement('script');
12 | script.src = url;
13 | script.setAttribute('data-ymc-id', id);
14 |
15 | document.head.appendChild(script);
16 |
17 | const port = browser.runtime.connect({ name: 'ymc' });
18 |
19 | port.onMessage.addListener(message => {
20 | window.postMessage({ type: 'YMC_EXT', data: message }, '*');
21 | });
22 |
23 | window.addEventListener('message', event => {
24 | if (event.source !== window || !(event.data.type && event.data.type === 'YMC_PAGE')) {
25 | return;
26 | }
27 |
28 | port.postMessage(event.data.data);
29 | });
30 | })();
31 |
--------------------------------------------------------------------------------
/native/.gitignore:
--------------------------------------------------------------------------------
1 | # Xcode
2 | #
3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore
4 |
5 | ## Build generated
6 | build/
7 | DerivedData/
8 |
9 | ## Various settings
10 | *.pbxuser
11 | !default.pbxuser
12 | *.mode1v3
13 | !default.mode1v3
14 | *.mode2v3
15 | !default.mode2v3
16 | *.perspectivev3
17 | !default.perspectivev3
18 | xcuserdata/
19 |
20 | ## Other
21 | *.moved-aside
22 | *.xccheckout
23 | *.xcscmblueprint
24 |
25 | ## Obj-C/Swift specific
26 | *.hmap
27 | *.ipa
28 | *.dSYM.zip
29 | *.dSYM
30 |
31 | ## Playgrounds
32 | timeline.xctimeline
33 | playground.xcworkspace
34 |
35 | # Swift Package Manager
36 | #
37 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies.
38 | # Packages/
39 | # Package.pins
40 | # Package.resolved
41 | .build/
42 |
43 | # CocoaPods
44 | #
45 | # We recommend against adding the Pods directory to your .gitignore. However
46 | # you should judge for yourself, the pros and cons are mentioned at:
47 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control
48 | #
49 | # Pods/
50 |
51 | # Carthage
52 | #
53 | # Add this line if you want to avoid checking in source code from Carthage dependencies.
54 | # Carthage/Checkouts
55 |
56 | Carthage/Build
57 | Carthage/Checkouts
58 |
59 | # fastlane
60 | #
61 | # It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the
62 | # screenshots whenever they are needed.
63 | # For more information about the recommended setup visit:
64 | # https://docs.fastlane.tools/best-practices/source-control/#source-control
65 |
66 | fastlane/report.xml
67 | fastlane/Preview.html
68 | fastlane/screenshots/**/*.png
69 | fastlane/test_output
70 |
71 |
--------------------------------------------------------------------------------
/native/Cartfile:
--------------------------------------------------------------------------------
1 | github "ReactiveX/RxSwift" ~> 4.0
2 | github "SwiftyBeaver/SwiftyBeaver"
3 | github "Ty3uK/MediaKeyTap" "master"
4 | github "JohnSundell/Files"
5 |
6 |
--------------------------------------------------------------------------------
/native/Cartfile.resolved:
--------------------------------------------------------------------------------
1 | github "JohnSundell/Files" "3.0.0"
2 | github "ReactiveX/RxSwift" "4.4.2"
3 | github "SwiftyBeaver/SwiftyBeaver" "1.7.0"
4 | github "Ty3uK/MediaKeyTap" "066ab19ffb1d44a02780f058af33a6155550e8ab"
5 |
--------------------------------------------------------------------------------
/native/YMC.xcodeproj/project.pbxproj:
--------------------------------------------------------------------------------
1 | // !$*UTF8*$!
2 | {
3 | archiveVersion = 1;
4 | classes = {
5 | };
6 | objectVersion = 50;
7 | objects = {
8 |
9 | /* Begin PBXBuildFile section */
10 | CE0E362F2212898700877823 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE0E362E2212898700877823 /* AppDelegate.swift */; };
11 | CE0E36332212898A00877823 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = CE0E36322212898A00877823 /* Assets.xcassets */; };
12 | CE0E36362212898A00877823 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = CE0E36342212898A00877823 /* Main.storyboard */; };
13 | CE19E05722226F2B0047B234 /* AppDelegate+MediaKeyTap.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE19E05622226F2B0047B234 /* AppDelegate+MediaKeyTap.swift */; };
14 | CE19E05922226FA60047B234 /* AppDelegate+Permissions.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE19E05822226FA60047B234 /* AppDelegate+Permissions.swift */; };
15 | CE19E05B22226FDF0047B234 /* AppDelegate+Notifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE19E05A22226FDF0047B234 /* AppDelegate+Notifications.swift */; };
16 | CE19E05D222270430047B234 /* AppDelegate+Messaging.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE19E05C222270430047B234 /* AppDelegate+Messaging.swift */; };
17 | CE8623A4221BD1D400AEEF59 /* BinUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE8623A3221BD1D400AEEF59 /* BinUtils.swift */; };
18 | CE8759722217390A008C8B5C /* Player.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE8759712217390A008C8B5C /* Player.swift */; };
19 | CE89AC772213EEC20028760B /* Messaging.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE89AC752213EEC20028760B /* Messaging.swift */; };
20 | CEC02744221E8A1A007B32EE /* Logger.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEC02743221E8A1A007B32EE /* Logger.swift */; };
21 | CECAAF7B221BD47900D17EC5 /* RxSwift.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CECAAF79221BD47900D17EC5 /* RxSwift.framework */; };
22 | CECAAF7C221BD47900D17EC5 /* RxSwift.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = CECAAF79221BD47900D17EC5 /* RxSwift.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
23 | CECAAF7D221BD47900D17EC5 /* RxAtomic.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CECAAF7A221BD47900D17EC5 /* RxAtomic.framework */; };
24 | CECAAF7E221BD47900D17EC5 /* RxAtomic.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = CECAAF7A221BD47900D17EC5 /* RxAtomic.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
25 | CECAAF82221BD48400D17EC5 /* SwiftyBeaver.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CECAAF80221BD48400D17EC5 /* SwiftyBeaver.framework */; };
26 | CECAAF83221BD48400D17EC5 /* SwiftyBeaver.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = CECAAF80221BD48400D17EC5 /* SwiftyBeaver.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
27 | CECAAF84221BD48400D17EC5 /* MediaKeyTap.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CECAAF81221BD48400D17EC5 /* MediaKeyTap.framework */; };
28 | CECAAF85221BD48400D17EC5 /* MediaKeyTap.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = CECAAF81221BD48400D17EC5 /* MediaKeyTap.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
29 | CECAAF88221BD5C400D17EC5 /* RxSwift.framework.dSYM in CopyFiles */ = {isa = PBXBuildFile; fileRef = CECAAF87221BD5C400D17EC5 /* RxSwift.framework.dSYM */; };
30 | CECAAF8A221BD5E300D17EC5 /* RxAtomic.framework.dSYM in CopyFiles */ = {isa = PBXBuildFile; fileRef = CECAAF89221BD5E300D17EC5 /* RxAtomic.framework.dSYM */; };
31 | CECAAF8C221BD5F300D17EC5 /* MediaKeyTap.framework.dSYM in CopyFiles */ = {isa = PBXBuildFile; fileRef = CECAAF8B221BD5F300D17EC5 /* MediaKeyTap.framework.dSYM */; };
32 | CECAAF8E221BD5FA00D17EC5 /* SwiftyBeaver.framework.dSYM in CopyFiles */ = {isa = PBXBuildFile; fileRef = CECAAF8D221BD5FA00D17EC5 /* SwiftyBeaver.framework.dSYM */; };
33 | CEDB4D2C221D1F2E0012D50D /* Manifest.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEDB4D2B221D1F2E0012D50D /* Manifest.swift */; };
34 | CEDB4D33221D26120012D50D /* Files.framework.dSYM in CopyFiles */ = {isa = PBXBuildFile; fileRef = CEDB4D32221D26120012D50D /* Files.framework.dSYM */; };
35 | CEDB4D35221D277E0012D50D /* Files.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CEDB4D34221D277E0012D50D /* Files.framework */; };
36 | CEDB4D36221D277E0012D50D /* Files.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = CEDB4D34221D277E0012D50D /* Files.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
37 | CEE809D922128B3200686E75 /* PlayerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEE809D822128B3200686E75 /* PlayerViewController.swift */; };
38 | /* End PBXBuildFile section */
39 |
40 | /* Begin PBXCopyFilesBuildPhase section */
41 | CECAAF7F221BD47900D17EC5 /* Embed Frameworks */ = {
42 | isa = PBXCopyFilesBuildPhase;
43 | buildActionMask = 2147483647;
44 | dstPath = "";
45 | dstSubfolderSpec = 10;
46 | files = (
47 | CECAAF7C221BD47900D17EC5 /* RxSwift.framework in Embed Frameworks */,
48 | CECAAF83221BD48400D17EC5 /* SwiftyBeaver.framework in Embed Frameworks */,
49 | CECAAF7E221BD47900D17EC5 /* RxAtomic.framework in Embed Frameworks */,
50 | CEDB4D36221D277E0012D50D /* Files.framework in Embed Frameworks */,
51 | CECAAF85221BD48400D17EC5 /* MediaKeyTap.framework in Embed Frameworks */,
52 | );
53 | name = "Embed Frameworks";
54 | runOnlyForDeploymentPostprocessing = 0;
55 | };
56 | CECAAF86221BD5A400D17EC5 /* CopyFiles */ = {
57 | isa = PBXCopyFilesBuildPhase;
58 | buildActionMask = 2147483647;
59 | dstPath = "";
60 | dstSubfolderSpec = 16;
61 | files = (
62 | CEDB4D33221D26120012D50D /* Files.framework.dSYM in CopyFiles */,
63 | CECAAF88221BD5C400D17EC5 /* RxSwift.framework.dSYM in CopyFiles */,
64 | CECAAF8A221BD5E300D17EC5 /* RxAtomic.framework.dSYM in CopyFiles */,
65 | CECAAF8C221BD5F300D17EC5 /* MediaKeyTap.framework.dSYM in CopyFiles */,
66 | CECAAF8E221BD5FA00D17EC5 /* SwiftyBeaver.framework.dSYM in CopyFiles */,
67 | );
68 | runOnlyForDeploymentPostprocessing = 0;
69 | };
70 | /* End PBXCopyFilesBuildPhase section */
71 |
72 | /* Begin PBXFileReference section */
73 | CE0E362B2212898700877823 /* YMC.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = YMC.app; sourceTree = BUILT_PRODUCTS_DIR; };
74 | CE0E362E2212898700877823 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; };
75 | CE0E36322212898A00877823 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; };
76 | CE0E36352212898A00877823 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; };
77 | CE0E36372212898A00877823 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; };
78 | CE0E36382212898A00877823 /* YMC.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = YMC.entitlements; sourceTree = ""; };
79 | CE19E05622226F2B0047B234 /* AppDelegate+MediaKeyTap.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppDelegate+MediaKeyTap.swift"; sourceTree = ""; };
80 | CE19E05822226FA60047B234 /* AppDelegate+Permissions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppDelegate+Permissions.swift"; sourceTree = ""; };
81 | CE19E05A22226FDF0047B234 /* AppDelegate+Notifications.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppDelegate+Notifications.swift"; sourceTree = ""; };
82 | CE19E05C222270430047B234 /* AppDelegate+Messaging.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppDelegate+Messaging.swift"; sourceTree = ""; };
83 | CE8623A3221BD1D400AEEF59 /* BinUtils.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BinUtils.swift; sourceTree = ""; };
84 | CE8759712217390A008C8B5C /* Player.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Player.swift; sourceTree = ""; };
85 | CE89AC752213EEC20028760B /* Messaging.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Messaging.swift; sourceTree = ""; };
86 | CEC02743221E8A1A007B32EE /* Logger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Logger.swift; sourceTree = ""; };
87 | CECAAF79221BD47900D17EC5 /* RxSwift.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = RxSwift.framework; path = Carthage/Build/Mac/RxSwift.framework; sourceTree = ""; };
88 | CECAAF7A221BD47900D17EC5 /* RxAtomic.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = RxAtomic.framework; path = Carthage/Build/Mac/RxAtomic.framework; sourceTree = ""; };
89 | CECAAF80221BD48400D17EC5 /* SwiftyBeaver.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftyBeaver.framework; path = Carthage/Build/Mac/SwiftyBeaver.framework; sourceTree = ""; };
90 | CECAAF81221BD48400D17EC5 /* MediaKeyTap.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = MediaKeyTap.framework; path = Carthage/Build/Mac/MediaKeyTap.framework; sourceTree = ""; };
91 | CECAAF87221BD5C400D17EC5 /* RxSwift.framework.dSYM */ = {isa = PBXFileReference; lastKnownFileType = wrapper.dsym; name = RxSwift.framework.dSYM; path = Carthage/Build/Mac/RxSwift.framework.dSYM; sourceTree = ""; };
92 | CECAAF89221BD5E300D17EC5 /* RxAtomic.framework.dSYM */ = {isa = PBXFileReference; lastKnownFileType = wrapper.dsym; name = RxAtomic.framework.dSYM; path = Carthage/Build/Mac/RxAtomic.framework.dSYM; sourceTree = ""; };
93 | CECAAF8B221BD5F300D17EC5 /* MediaKeyTap.framework.dSYM */ = {isa = PBXFileReference; lastKnownFileType = wrapper.dsym; name = MediaKeyTap.framework.dSYM; path = Carthage/Build/Mac/MediaKeyTap.framework.dSYM; sourceTree = ""; };
94 | CECAAF8D221BD5FA00D17EC5 /* SwiftyBeaver.framework.dSYM */ = {isa = PBXFileReference; lastKnownFileType = wrapper.dsym; name = SwiftyBeaver.framework.dSYM; path = Carthage/Build/Mac/SwiftyBeaver.framework.dSYM; sourceTree = ""; };
95 | CEDB4D2B221D1F2E0012D50D /* Manifest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Manifest.swift; sourceTree = ""; };
96 | CEDB4D32221D26120012D50D /* Files.framework.dSYM */ = {isa = PBXFileReference; lastKnownFileType = wrapper.dsym; name = Files.framework.dSYM; path = Carthage/Build/Mac/Files.framework.dSYM; sourceTree = ""; };
97 | CEDB4D34221D277E0012D50D /* Files.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Files.framework; path = Carthage/Build/Mac/Files.framework; sourceTree = ""; };
98 | CEE809D822128B3200686E75 /* PlayerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerViewController.swift; sourceTree = ""; };
99 | /* End PBXFileReference section */
100 |
101 | /* Begin PBXFrameworksBuildPhase section */
102 | CE0E36282212898700877823 /* Frameworks */ = {
103 | isa = PBXFrameworksBuildPhase;
104 | buildActionMask = 2147483647;
105 | files = (
106 | CECAAF7B221BD47900D17EC5 /* RxSwift.framework in Frameworks */,
107 | CECAAF82221BD48400D17EC5 /* SwiftyBeaver.framework in Frameworks */,
108 | CECAAF7D221BD47900D17EC5 /* RxAtomic.framework in Frameworks */,
109 | CEDB4D35221D277E0012D50D /* Files.framework in Frameworks */,
110 | CECAAF84221BD48400D17EC5 /* MediaKeyTap.framework in Frameworks */,
111 | );
112 | runOnlyForDeploymentPostprocessing = 0;
113 | };
114 | /* End PBXFrameworksBuildPhase section */
115 |
116 | /* Begin PBXGroup section */
117 | CE0E36222212898700877823 = {
118 | isa = PBXGroup;
119 | children = (
120 | CEDB4D34221D277E0012D50D /* Files.framework */,
121 | CEDB4D32221D26120012D50D /* Files.framework.dSYM */,
122 | CECAAF7A221BD47900D17EC5 /* RxAtomic.framework */,
123 | CECAAF89221BD5E300D17EC5 /* RxAtomic.framework.dSYM */,
124 | CECAAF79221BD47900D17EC5 /* RxSwift.framework */,
125 | CECAAF87221BD5C400D17EC5 /* RxSwift.framework.dSYM */,
126 | CECAAF81221BD48400D17EC5 /* MediaKeyTap.framework */,
127 | CECAAF8B221BD5F300D17EC5 /* MediaKeyTap.framework.dSYM */,
128 | CECAAF80221BD48400D17EC5 /* SwiftyBeaver.framework */,
129 | CECAAF8D221BD5FA00D17EC5 /* SwiftyBeaver.framework.dSYM */,
130 | CE0E362D2212898700877823 /* YMC */,
131 | CE0E362C2212898700877823 /* Products */,
132 | );
133 | sourceTree = "";
134 | };
135 | CE0E362C2212898700877823 /* Products */ = {
136 | isa = PBXGroup;
137 | children = (
138 | CE0E362B2212898700877823 /* YMC.app */,
139 | );
140 | name = Products;
141 | sourceTree = "";
142 | };
143 | CE0E362D2212898700877823 /* YMC */ = {
144 | isa = PBXGroup;
145 | children = (
146 | CE0E36342212898A00877823 /* Main.storyboard */,
147 | CE0E36382212898A00877823 /* YMC.entitlements */,
148 | CE0E36322212898A00877823 /* Assets.xcassets */,
149 | CE0E36372212898A00877823 /* Info.plist */,
150 | CE8623A0221BD17500AEEF59 /* Application */,
151 | CE8623A1221BD18C00AEEF59 /* Classes */,
152 | CE8623A2221BD1A500AEEF59 /* Vendor */,
153 | );
154 | path = YMC;
155 | sourceTree = "";
156 | };
157 | CE19E05522226F020047B234 /* AppDelegate */ = {
158 | isa = PBXGroup;
159 | children = (
160 | CE0E362E2212898700877823 /* AppDelegate.swift */,
161 | CE19E05622226F2B0047B234 /* AppDelegate+MediaKeyTap.swift */,
162 | CE19E05822226FA60047B234 /* AppDelegate+Permissions.swift */,
163 | CE19E05A22226FDF0047B234 /* AppDelegate+Notifications.swift */,
164 | CE19E05C222270430047B234 /* AppDelegate+Messaging.swift */,
165 | );
166 | path = AppDelegate;
167 | sourceTree = "";
168 | };
169 | CE8623A0221BD17500AEEF59 /* Application */ = {
170 | isa = PBXGroup;
171 | children = (
172 | CE19E05522226F020047B234 /* AppDelegate */,
173 | CEE809D822128B3200686E75 /* PlayerViewController.swift */,
174 | );
175 | path = Application;
176 | sourceTree = "";
177 | };
178 | CE8623A1221BD18C00AEEF59 /* Classes */ = {
179 | isa = PBXGroup;
180 | children = (
181 | CE89AC752213EEC20028760B /* Messaging.swift */,
182 | CE8759712217390A008C8B5C /* Player.swift */,
183 | CEDB4D2B221D1F2E0012D50D /* Manifest.swift */,
184 | CEC02743221E8A1A007B32EE /* Logger.swift */,
185 | );
186 | path = Classes;
187 | sourceTree = "";
188 | };
189 | CE8623A2221BD1A500AEEF59 /* Vendor */ = {
190 | isa = PBXGroup;
191 | children = (
192 | CE8623A3221BD1D400AEEF59 /* BinUtils.swift */,
193 | );
194 | path = Vendor;
195 | sourceTree = "";
196 | };
197 | /* End PBXGroup section */
198 |
199 | /* Begin PBXNativeTarget section */
200 | CE0E362A2212898700877823 /* YMC */ = {
201 | isa = PBXNativeTarget;
202 | buildConfigurationList = CE0E363B2212898A00877823 /* Build configuration list for PBXNativeTarget "YMC" */;
203 | buildPhases = (
204 | CE0E36272212898700877823 /* Sources */,
205 | CE0E36282212898700877823 /* Frameworks */,
206 | CE0E36292212898700877823 /* Resources */,
207 | CECAAF7F221BD47900D17EC5 /* Embed Frameworks */,
208 | CECAAF86221BD5A400D17EC5 /* CopyFiles */,
209 | );
210 | buildRules = (
211 | );
212 | dependencies = (
213 | );
214 | name = YMC;
215 | productName = YMC;
216 | productReference = CE0E362B2212898700877823 /* YMC.app */;
217 | productType = "com.apple.product-type.application";
218 | };
219 | /* End PBXNativeTarget section */
220 |
221 | /* Begin PBXProject section */
222 | CE0E36232212898700877823 /* Project object */ = {
223 | isa = PBXProject;
224 | attributes = {
225 | LastSwiftUpdateCheck = 1010;
226 | LastUpgradeCheck = 1010;
227 | ORGANIZATIONNAME = "Maksim Karelov";
228 | TargetAttributes = {
229 | CE0E362A2212898700877823 = {
230 | CreatedOnToolsVersion = 10.1;
231 | LastSwiftMigration = 1020;
232 | SystemCapabilities = {
233 | com.apple.HardenedRuntime = {
234 | enabled = 0;
235 | };
236 | com.apple.Sandbox = {
237 | enabled = 0;
238 | };
239 | };
240 | };
241 | };
242 | };
243 | buildConfigurationList = CE0E36262212898700877823 /* Build configuration list for PBXProject "YMC" */;
244 | compatibilityVersion = "Xcode 9.3";
245 | developmentRegion = en;
246 | hasScannedForEncodings = 0;
247 | knownRegions = (
248 | en,
249 | Base,
250 | );
251 | mainGroup = CE0E36222212898700877823;
252 | productRefGroup = CE0E362C2212898700877823 /* Products */;
253 | projectDirPath = "";
254 | projectRoot = "";
255 | targets = (
256 | CE0E362A2212898700877823 /* YMC */,
257 | );
258 | };
259 | /* End PBXProject section */
260 |
261 | /* Begin PBXResourcesBuildPhase section */
262 | CE0E36292212898700877823 /* Resources */ = {
263 | isa = PBXResourcesBuildPhase;
264 | buildActionMask = 2147483647;
265 | files = (
266 | CE0E36332212898A00877823 /* Assets.xcassets in Resources */,
267 | CE0E36362212898A00877823 /* Main.storyboard in Resources */,
268 | );
269 | runOnlyForDeploymentPostprocessing = 0;
270 | };
271 | /* End PBXResourcesBuildPhase section */
272 |
273 | /* Begin PBXSourcesBuildPhase section */
274 | CE0E36272212898700877823 /* Sources */ = {
275 | isa = PBXSourcesBuildPhase;
276 | buildActionMask = 2147483647;
277 | files = (
278 | CE89AC772213EEC20028760B /* Messaging.swift in Sources */,
279 | CE8623A4221BD1D400AEEF59 /* BinUtils.swift in Sources */,
280 | CEDB4D2C221D1F2E0012D50D /* Manifest.swift in Sources */,
281 | CE19E05D222270430047B234 /* AppDelegate+Messaging.swift in Sources */,
282 | CE0E362F2212898700877823 /* AppDelegate.swift in Sources */,
283 | CEC02744221E8A1A007B32EE /* Logger.swift in Sources */,
284 | CE19E05B22226FDF0047B234 /* AppDelegate+Notifications.swift in Sources */,
285 | CE19E05922226FA60047B234 /* AppDelegate+Permissions.swift in Sources */,
286 | CE19E05722226F2B0047B234 /* AppDelegate+MediaKeyTap.swift in Sources */,
287 | CE8759722217390A008C8B5C /* Player.swift in Sources */,
288 | CEE809D922128B3200686E75 /* PlayerViewController.swift in Sources */,
289 | );
290 | runOnlyForDeploymentPostprocessing = 0;
291 | };
292 | /* End PBXSourcesBuildPhase section */
293 |
294 | /* Begin PBXVariantGroup section */
295 | CE0E36342212898A00877823 /* Main.storyboard */ = {
296 | isa = PBXVariantGroup;
297 | children = (
298 | CE0E36352212898A00877823 /* Base */,
299 | );
300 | name = Main.storyboard;
301 | sourceTree = "";
302 | };
303 | /* End PBXVariantGroup section */
304 |
305 | /* Begin XCBuildConfiguration section */
306 | CE0E36392212898A00877823 /* Debug */ = {
307 | isa = XCBuildConfiguration;
308 | buildSettings = {
309 | ALWAYS_SEARCH_USER_PATHS = NO;
310 | CLANG_ANALYZER_NONNULL = YES;
311 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
312 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
313 | CLANG_CXX_LIBRARY = "libc++";
314 | CLANG_ENABLE_MODULES = YES;
315 | CLANG_ENABLE_OBJC_ARC = YES;
316 | CLANG_ENABLE_OBJC_WEAK = YES;
317 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
318 | CLANG_WARN_BOOL_CONVERSION = YES;
319 | CLANG_WARN_COMMA = YES;
320 | CLANG_WARN_CONSTANT_CONVERSION = YES;
321 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
322 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
323 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
324 | CLANG_WARN_EMPTY_BODY = YES;
325 | CLANG_WARN_ENUM_CONVERSION = YES;
326 | CLANG_WARN_INFINITE_RECURSION = YES;
327 | CLANG_WARN_INT_CONVERSION = YES;
328 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
329 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
330 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
331 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
332 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
333 | CLANG_WARN_STRICT_PROTOTYPES = YES;
334 | CLANG_WARN_SUSPICIOUS_MOVE = YES;
335 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
336 | CLANG_WARN_UNREACHABLE_CODE = YES;
337 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
338 | CODE_SIGN_IDENTITY = "Mac Developer";
339 | COPY_PHASE_STRIP = NO;
340 | DEBUG_INFORMATION_FORMAT = dwarf;
341 | ENABLE_STRICT_OBJC_MSGSEND = YES;
342 | ENABLE_TESTABILITY = YES;
343 | GCC_C_LANGUAGE_STANDARD = gnu11;
344 | GCC_DYNAMIC_NO_PIC = NO;
345 | GCC_NO_COMMON_BLOCKS = YES;
346 | GCC_OPTIMIZATION_LEVEL = 0;
347 | GCC_PREPROCESSOR_DEFINITIONS = (
348 | "DEBUG=1",
349 | "$(inherited)",
350 | );
351 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
352 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
353 | GCC_WARN_UNDECLARED_SELECTOR = YES;
354 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
355 | GCC_WARN_UNUSED_FUNCTION = YES;
356 | GCC_WARN_UNUSED_VARIABLE = YES;
357 | MACOSX_DEPLOYMENT_TARGET = 10.11;
358 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
359 | MTL_FAST_MATH = YES;
360 | ONLY_ACTIVE_ARCH = YES;
361 | SDKROOT = macosx;
362 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
363 | SWIFT_OPTIMIZATION_LEVEL = "-Onone";
364 | };
365 | name = Debug;
366 | };
367 | CE0E363A2212898A00877823 /* Release */ = {
368 | isa = XCBuildConfiguration;
369 | buildSettings = {
370 | ALWAYS_SEARCH_USER_PATHS = NO;
371 | CLANG_ANALYZER_NONNULL = YES;
372 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
373 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
374 | CLANG_CXX_LIBRARY = "libc++";
375 | CLANG_ENABLE_MODULES = YES;
376 | CLANG_ENABLE_OBJC_ARC = YES;
377 | CLANG_ENABLE_OBJC_WEAK = YES;
378 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
379 | CLANG_WARN_BOOL_CONVERSION = YES;
380 | CLANG_WARN_COMMA = YES;
381 | CLANG_WARN_CONSTANT_CONVERSION = YES;
382 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
383 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
384 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
385 | CLANG_WARN_EMPTY_BODY = YES;
386 | CLANG_WARN_ENUM_CONVERSION = YES;
387 | CLANG_WARN_INFINITE_RECURSION = YES;
388 | CLANG_WARN_INT_CONVERSION = YES;
389 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
390 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
391 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
392 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
393 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
394 | CLANG_WARN_STRICT_PROTOTYPES = YES;
395 | CLANG_WARN_SUSPICIOUS_MOVE = YES;
396 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
397 | CLANG_WARN_UNREACHABLE_CODE = YES;
398 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
399 | CODE_SIGN_IDENTITY = "Mac Developer";
400 | COPY_PHASE_STRIP = NO;
401 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
402 | ENABLE_NS_ASSERTIONS = NO;
403 | ENABLE_STRICT_OBJC_MSGSEND = YES;
404 | GCC_C_LANGUAGE_STANDARD = gnu11;
405 | GCC_NO_COMMON_BLOCKS = YES;
406 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
407 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
408 | GCC_WARN_UNDECLARED_SELECTOR = YES;
409 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
410 | GCC_WARN_UNUSED_FUNCTION = YES;
411 | GCC_WARN_UNUSED_VARIABLE = YES;
412 | MACOSX_DEPLOYMENT_TARGET = 10.11;
413 | MTL_ENABLE_DEBUG_INFO = NO;
414 | MTL_FAST_MATH = YES;
415 | SDKROOT = macosx;
416 | SWIFT_COMPILATION_MODE = wholemodule;
417 | SWIFT_OPTIMIZATION_LEVEL = "-O";
418 | };
419 | name = Release;
420 | };
421 | CE0E363C2212898A00877823 /* Debug */ = {
422 | isa = XCBuildConfiguration;
423 | buildSettings = {
424 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
425 | CODE_SIGN_STYLE = Automatic;
426 | COMBINE_HIDPI_IMAGES = YES;
427 | DEVELOPMENT_TEAM = 4TH28AB8A3;
428 | FRAMEWORK_SEARCH_PATHS = (
429 | "$(inherited)",
430 | "$(PROJECT_DIR)/Carthage/Build/Mac",
431 | );
432 | INFOPLIST_FILE = YMC/Info.plist;
433 | LD_RUNPATH_SEARCH_PATHS = (
434 | "$(inherited)",
435 | "@executable_path/../Frameworks",
436 | );
437 | PRODUCT_BUNDLE_IDENTIFIER = info.karelov.YMC;
438 | PRODUCT_NAME = "$(TARGET_NAME)";
439 | SWIFT_VERSION = 5.0;
440 | };
441 | name = Debug;
442 | };
443 | CE0E363D2212898A00877823 /* Release */ = {
444 | isa = XCBuildConfiguration;
445 | buildSettings = {
446 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
447 | CODE_SIGN_STYLE = Automatic;
448 | COMBINE_HIDPI_IMAGES = YES;
449 | DEVELOPMENT_TEAM = 4TH28AB8A3;
450 | FRAMEWORK_SEARCH_PATHS = (
451 | "$(inherited)",
452 | "$(PROJECT_DIR)/Carthage/Build/Mac",
453 | );
454 | INFOPLIST_FILE = YMC/Info.plist;
455 | LD_RUNPATH_SEARCH_PATHS = (
456 | "$(inherited)",
457 | "@executable_path/../Frameworks",
458 | );
459 | PRODUCT_BUNDLE_IDENTIFIER = info.karelov.YMC;
460 | PRODUCT_NAME = "$(TARGET_NAME)";
461 | SWIFT_VERSION = 5.0;
462 | };
463 | name = Release;
464 | };
465 | /* End XCBuildConfiguration section */
466 |
467 | /* Begin XCConfigurationList section */
468 | CE0E36262212898700877823 /* Build configuration list for PBXProject "YMC" */ = {
469 | isa = XCConfigurationList;
470 | buildConfigurations = (
471 | CE0E36392212898A00877823 /* Debug */,
472 | CE0E363A2212898A00877823 /* Release */,
473 | );
474 | defaultConfigurationIsVisible = 0;
475 | defaultConfigurationName = Release;
476 | };
477 | CE0E363B2212898A00877823 /* Build configuration list for PBXNativeTarget "YMC" */ = {
478 | isa = XCConfigurationList;
479 | buildConfigurations = (
480 | CE0E363C2212898A00877823 /* Debug */,
481 | CE0E363D2212898A00877823 /* Release */,
482 | );
483 | defaultConfigurationIsVisible = 0;
484 | defaultConfigurationName = Release;
485 | };
486 | /* End XCConfigurationList section */
487 | };
488 | rootObject = CE0E36232212898700877823 /* Project object */;
489 | }
490 |
--------------------------------------------------------------------------------
/native/YMC.xcodeproj/project.xcworkspace/contents.xcworkspacedata:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/native/YMC.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | IDEDidComputeMac32BitWarning
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/native/YMC/Application/AppDelegate/AppDelegate+MediaKeyTap.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AppDelegate+MediaKeyTap.swift
3 | // YMC
4 | //
5 | // Created by Максим Карелов on 24/02/2019.
6 | // Copyright © 2019 Maksim Karelov. All rights reserved.
7 | //
8 |
9 | import MediaKeyTap
10 |
11 | extension AppDelegate: MediaKeyTapDelegate {
12 | func handle(mediaKey: MediaKey, event: KeyEvent) {
13 | switch mediaKey {
14 | case .playPause:
15 | if event.modifierFlags.contains(.command) {
16 | Player.instance.emitPressedButton(.TRACK_INFO)
17 | } else {
18 | Player.instance.emitPressedButton(.PLAY_PAUSE)
19 | }
20 | case .fastForward:
21 | if event.modifierFlags.contains(.command) {
22 | Player.instance.emitPressedButton(.LIKE)
23 | } else {
24 | Player.instance.emitPressedButton(.NEXT)
25 | }
26 | case .rewind:
27 | if event.modifierFlags.contains(.command) {
28 | Player.instance.emitPressedButton(.LINK)
29 | } else {
30 | Player.instance.emitPressedButton(.PREV)
31 | }
32 | default:
33 | return
34 | }
35 | }
36 |
37 | func readKeys() {
38 | if !isAccessibilityAvailable { return }
39 |
40 | mediaKeyTap = MediaKeyTap(delegate: self)
41 | mediaKeyTap?.start()
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/native/YMC/Application/AppDelegate/AppDelegate+Messaging.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AppDelegate+Messaging.swift
3 | // YMC
4 | //
5 | // Created by Максим Карелов on 24/02/2019.
6 | // Copyright © 2019 Maksim Karelov. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | extension AppDelegate {
12 | func setupIncomingMessage() {
13 | IncomingMessage.register(CurrentTrack.self, for: "TRACK_INFO")
14 | IncomingMessage.register(CurrentTrack.self, for: "CURRENT_TRACK")
15 | IncomingMessage.register(PlayerState.self, for: "PLAYER_STATE")
16 | IncomingMessage.register(Controls.self, for: "CONTROLS")
17 | IncomingMessage.register(IsPlaying.self, for: "PLAYING")
18 | IncomingMessage.register(IsLiked.self, for: "TOGGLE_LIKE")
19 | }
20 |
21 | func setupRefreshInterval() {
22 | Timer.scheduledTimer(timeInterval: 30, target: self, selector: #selector(refreshInterval(_:)), userInfo: nil, repeats: true)
23 | }
24 |
25 | @objc func refreshInterval(_ timer: Timer) {
26 | sendMessageToFrontend("REFRESH")
27 | }
28 |
29 | func readMessages() {
30 | standardInput.readabilityHandler = { pipe in
31 | let data = pipe.availableData
32 |
33 | if data.count == 0 || data.count == 4 { return }
34 |
35 | guard let unpacked = try? unpack("=I", data.subdata(in: 0..<4)) else { return }
36 | guard let length = unpacked[0] as? Int else { return }
37 |
38 | var message: String
39 |
40 | if length > 4096 {
41 | message = String(data: data, encoding: .utf8) ?? ""
42 | } else {
43 | message = String(data: data.subdata(in: 4..<(length + 4)), encoding: .utf8) ?? ""
44 | }
45 |
46 | if message.count == 0 { return }
47 |
48 | self.handleMessage(message.data(using: .utf8)!)
49 | }
50 | }
51 |
52 | func handleMessage(_ message: Data) {
53 | guard let json = try? JSONDecoder().decode(IncomingMessage.self, from: message) else { return }
54 |
55 | Logger.instance.log(logLevel: .info, message: "MESSAGE FROM EXT: \(json)")
56 |
57 | switch json.data {
58 | case let data as PlayerState:
59 | Player.instance.changeCoverImage(data.currentTrack.cover)
60 | Player.instance.changeCurrentTrack(data.currentTrack)
61 | Player.instance.changeControls(data.controls)
62 | Player.instance.changePlayingState(data.isPlaying)
63 | case let data as CurrentTrack:
64 | Player.instance.changeCoverImage(data.cover)
65 | Player.instance.changeCurrentTrack(data)
66 | case let data as Controls:
67 | Player.instance.changeControls(data)
68 | case let data as IsPlaying:
69 | Player.instance.changePlayingState(data.state)
70 | case let data as IsLiked:
71 | Player.instance.changeLikedState(data.state)
72 | default:
73 | return
74 | }
75 | }
76 | }
77 |
--------------------------------------------------------------------------------
/native/YMC/Application/AppDelegate/AppDelegate+Notifications.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AppDelegate+Notifications.swift
3 | // YMC
4 | //
5 | // Created by Максим Карелов on 24/02/2019.
6 | // Copyright © 2019 Maksim Karelov. All rights reserved.
7 | //
8 |
9 | import Foundation
10 | import Cocoa
11 |
12 | extension AppDelegate {
13 | func showTrackInfoNotification() {
14 | guard let currentTrack = Player.instance.currentTrack else { return }
15 | let notification = NSUserNotification()
16 |
17 | notification.title = currentTrack.artist
18 | notification.informativeText = currentTrack.title
19 | notification.contentImage = Player.instance.coverImage
20 |
21 | NSUserNotificationCenter.default.deliver(notification)
22 | }
23 |
24 | func copyTrackLinkToClipboard() {
25 | if let link = Player.instance.currentTrack?.link?.absoluteString {
26 | let clipboard = NSPasteboard.general
27 | clipboard.clearContents()
28 | clipboard.setString(link, forType: .string)
29 | }
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/native/YMC/Application/AppDelegate/AppDelegate+Permissions.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AppDelegate+Setup.swift
3 | // YMC
4 | //
5 | // Created by Максим Карелов on 24/02/2019.
6 | // Copyright © 2019 Maksim Karelov. All rights reserved.
7 | //
8 |
9 | import Foundation
10 | import Cocoa
11 |
12 | extension AppDelegate {
13 | func prepareLaunch() {
14 | let options = [kAXTrustedCheckOptionPrompt.takeRetainedValue() as String: true] as CFDictionary
15 |
16 | if !AXIsProcessTrustedWithOptions(options) {
17 | showAlert(
18 | title: "Action required",
19 | message: "To be able to switch songs by pressing media keys you must activate the app in accessibility settings",
20 | quitAfter: false
21 | )
22 | } else {
23 | isAccessibilityAvailable = true
24 | }
25 |
26 | let writeManifestStatus = processManifests()
27 |
28 | switch writeManifestStatus {
29 | case .error:
30 | showAlert(
31 | title: "Error",
32 | message: "Cannot write manifest file for extension.\nApp will be closed.",
33 | quitAfter: true
34 | )
35 | case .updated:
36 | showAlert(
37 | title: "Action required",
38 | message: "Manifest updated. Please, restart your browser.",
39 | quitAfter: true
40 | )
41 | default:
42 | return
43 | }
44 | }
45 |
46 | private func showAlert(title: String, message: String, quitAfter: Bool) {
47 | let alert = NSAlert()
48 |
49 | alert.messageText = title
50 | alert.informativeText = message
51 | alert.addButton(withTitle: "Got it")
52 |
53 | if quitAfter && alert.runModal() == .alertFirstButtonReturn {
54 | exit(1)
55 | }
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/native/YMC/Application/AppDelegate/AppDelegate.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AppDelegate.swift
3 | // YMC
4 | //
5 | // Created by Максим Карелов on 12/02/2019.
6 | // Copyright © 2019 Maksim Karelov. All rights reserved.
7 | //
8 |
9 | import Cocoa
10 | import MediaKeyTap
11 | import RxSwift
12 |
13 | @NSApplicationMain
14 | class AppDelegate: NSObject, NSApplicationDelegate {
15 | let standardInput = FileHandle.standardInput
16 |
17 | let statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
18 | let popover = NSPopover()
19 |
20 | let runStdinMonitoring = CommandLine.arguments.first(where: {
21 | $0.contains("ymc.json") || $0.contains("chrome-extension://")
22 | }) != nil
23 | var playerViewController: PlayerViewController? = nil
24 | var mediaKeyTap: MediaKeyTap? = nil
25 | var isAccessibilityAvailable = false
26 |
27 | let disposeBag = DisposeBag()
28 |
29 | func applicationDidFinishLaunching(_ aNotification: Notification) {
30 | prepareLaunch()
31 |
32 | playerViewController = PlayerViewController.freshController()
33 |
34 | setupIncomingMessage()
35 | setupStatusItem()
36 | setupPopover()
37 | setupRefreshInterval()
38 | readKeys()
39 |
40 | if runStdinMonitoring {
41 | readMessages()
42 | }
43 |
44 | Logger.instance.log(logLevel: .info, message: "runStdinMonitoring: \(runStdinMonitoring)")
45 |
46 | Player.instance.buttonPress$.subscribe { event in
47 | guard let button = event.element else { return }
48 |
49 | switch button {
50 | case .TRACK_INFO:
51 | self.showTrackInfoNotification()
52 | case .LINK:
53 | self.copyTrackLinkToClipboard()
54 | case .PLAY_PAUSE:
55 | self.sendMessageToFrontend("PLAY_PAUSE")
56 | case .LIKE:
57 | self.sendMessageToFrontend("TOGGLE_LIKE")
58 | case .NEXT:
59 | self.sendMessageToFrontend("NEXT")
60 | case .PREV:
61 | self.sendMessageToFrontend("PREV")
62 | case .REFRESH:
63 | self.sendMessageToFrontend("REFRESH")
64 | default:
65 | return
66 | }
67 | }.disposed(by: disposeBag)
68 |
69 | DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
70 | self.sendMessageToFrontend("REFRESH")
71 | }
72 | }
73 |
74 | func applicationWillTerminate(_ aNotification: Notification) {
75 | // Insert code here to tear down your application
76 | }
77 |
78 | func sendMessageToFrontend(_ messageType: String) {
79 | if runStdinMonitoring {
80 | writeMessage(messageType)
81 | }
82 | }
83 |
84 | @objc func togglePopover(_ sender: NSButton) {
85 | if popover.isShown {
86 | popover.performClose(sender)
87 | } else {
88 | if let button = statusItem.button {
89 | NSApplication.shared.activate(ignoringOtherApps: true)
90 | popover.show(relativeTo: button.bounds, of: button, preferredEdge: NSRectEdge.minY)
91 | }
92 | }
93 | }
94 |
95 | private func setupStatusItem(_ title: String? = nil) {
96 | if let button = statusItem.button {
97 | button.image = NSImage(named: "logo")
98 | button.action = #selector(togglePopover(_:))
99 | button.sendAction(on: [.leftMouseDown, .rightMouseDown])
100 | }
101 | }
102 |
103 | private func setupPopover() {
104 | popover.behavior = .transient
105 | popover.contentViewController = playerViewController
106 | }
107 | }
108 |
--------------------------------------------------------------------------------
/native/YMC/Application/PlayerViewController.swift:
--------------------------------------------------------------------------------
1 | //
2 | // PlayerViewController.swift
3 | // YMC
4 | //
5 | // Created by Максим Карелов on 12/02/2019.
6 | // Copyright © 2019 Maksim Karelov. All rights reserved.
7 | //
8 |
9 | import Cocoa
10 | import RxSwift
11 | import SwiftyBeaver
12 |
13 | class PlayerViewController: NSViewController {
14 |
15 | @IBOutlet var songCover: NSImageView!
16 | @IBOutlet var songTitle: NSTextField!
17 | @IBOutlet var songArtist: NSTextField!
18 |
19 | @IBOutlet var rewindButton: NSButton!
20 | @IBOutlet var playPauseButton: NSButton!
21 | @IBOutlet var forwardButton: NSButton!
22 |
23 | @IBOutlet var linkButton: NSButton!
24 | @IBOutlet var likeButton: NSButton!
25 | @IBOutlet var settingsButton: NSButton!
26 | @IBOutlet var settingsPopupButton: NSPopUpButton!
27 |
28 | private let disposeBag = DisposeBag()
29 |
30 | private var invisibleWindow: NSWindow? = nil
31 |
32 | required init?(coder: NSCoder) {
33 | super.init(coder: coder)
34 | }
35 |
36 | override func viewDidLoad() {
37 | super.viewDidLoad()
38 |
39 | linkButton.sendAction(on: .leftMouseDown)
40 |
41 | Player.instance.currentTrack$.subscribe { event in
42 | guard let currentTrack = event.element else { return }
43 |
44 | DispatchQueue.main.async {
45 | self.songTitle.stringValue = currentTrack.title
46 | self.songArtist.stringValue = currentTrack.artist
47 | self.likeButton.state = currentTrack.liked ? .on : .off
48 | }
49 | }.disposed(by: disposeBag)
50 |
51 | Player.instance.coverImage$.subscribe { event in
52 | guard let coverImage = event.element else { return }
53 |
54 | DispatchQueue.main.async {
55 | self.songCover.image = coverImage
56 | }
57 | }.disposed(by: disposeBag)
58 |
59 | Player.instance.playingState$.subscribe { event in
60 | guard let playingState = event.element else { return }
61 | let image = NSImage(named: (playingState ? "pause" : "play"))
62 |
63 | DispatchQueue.main.async {
64 | self.playPauseButton.image = image
65 | }
66 | }.disposed(by: disposeBag)
67 |
68 | Player.instance.availableControls$.subscribe { event in
69 | guard let controls = event.element else { return }
70 |
71 | DispatchQueue.main.async {
72 | self.likeButton.isEnabled = controls.like ?? false
73 | self.rewindButton.isEnabled = controls.prev ?? false
74 | self.forwardButton.isEnabled = controls.next ?? false
75 | }
76 | }.disposed(by: disposeBag)
77 | }
78 |
79 | @IBAction func buttonClick(_ sender: NSButton) {
80 | guard let identifier = sender.identifier?.rawValue else { return }
81 |
82 | switch identifier {
83 | case "play_pause":
84 | Player.instance.emitPressedButton(.PLAY_PAUSE)
85 | case "like":
86 | Player.instance.emitPressedButton(.LIKE)
87 | case "next":
88 | Player.instance.emitPressedButton(.NEXT)
89 | case "prev":
90 | Player.instance.emitPressedButton(.PREV)
91 | case "refresh":
92 | Player.instance.emitPressedButton(.REFRESH)
93 | default:
94 | return
95 | }
96 | }
97 |
98 | @IBAction func menuItemClick(_ sender: NSMenuItem) {
99 | guard let identifier = sender.identifier?.rawValue else { return }
100 |
101 | switch identifier {
102 | case "quit":
103 | exit(0)
104 | default:
105 | return
106 | }
107 | }
108 |
109 |
110 | @IBAction func settingsButtonClick(_ sender: NSButton) {
111 | settingsPopupButton.performClick(sender)
112 | }
113 |
114 | @IBAction func shareButtonClick(_ sender: NSButton) {
115 | guard let url = Player.instance.currentTrack?.link else { return }
116 |
117 | // Create a window
118 | invisibleWindow = NSWindow(contentRect: NSMakeRect(0, 0, 20, 5), styleMask: .borderless, backing: .buffered, defer: false)
119 | invisibleWindow!.backgroundColor = .red
120 | invisibleWindow!.alphaValue = 0
121 |
122 | // find the coordinates of the statusBarItem in screen space
123 | let buttonRect: NSRect = sender.convert(sender.bounds, to: nil)
124 | let screenRect: NSRect = sender.window!.convertToScreen(buttonRect)
125 |
126 | // calculate the bottom center position (10 is the half of the window width)
127 | let posX = screenRect.origin.x + (screenRect.width / 2) - 10
128 | let posY = screenRect.origin.y
129 |
130 | // position and show the window
131 | invisibleWindow!.setFrameOrigin(NSPoint(x: posX, y: posY))
132 | invisibleWindow!.makeKeyAndOrderFront(self)
133 |
134 | guard let view = invisibleWindow!.contentView else { return }
135 |
136 | let picker = NSSharingServicePicker(items: [url.absoluteString])
137 |
138 | picker.delegate = self
139 | picker.show(relativeTo: view.frame, of: view, preferredEdge: .minY)
140 | }
141 | }
142 |
143 | extension PlayerViewController {
144 | static func freshController() -> PlayerViewController {
145 | let storyboard = NSStoryboard(name: NSStoryboard.Name("Main"), bundle: nil)
146 | let identifier = NSStoryboard.SceneIdentifier("PlayerViewController")
147 |
148 | guard let viewController = storyboard.instantiateController(withIdentifier: identifier) as? PlayerViewController else {
149 | fatalError("Cannot find PlayerViewController")
150 | }
151 |
152 | return viewController
153 | }
154 | }
155 |
156 | extension PlayerViewController: NSSharingServicePickerDelegate {
157 | func sharingServicePicker(_ sharingServicePicker: NSSharingServicePicker, didChoose service: NSSharingService?) {
158 | invisibleWindow?.close()
159 | }
160 | }
161 |
--------------------------------------------------------------------------------
/native/YMC/Assets.xcassets/AppIcon.appiconset/1024.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Ty3uK/YMC/a4ae8f63b5a809f3b83860972f0bef49bf28118f/native/YMC/Assets.xcassets/AppIcon.appiconset/1024.png
--------------------------------------------------------------------------------
/native/YMC/Assets.xcassets/AppIcon.appiconset/128.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Ty3uK/YMC/a4ae8f63b5a809f3b83860972f0bef49bf28118f/native/YMC/Assets.xcassets/AppIcon.appiconset/128.png
--------------------------------------------------------------------------------
/native/YMC/Assets.xcassets/AppIcon.appiconset/16.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Ty3uK/YMC/a4ae8f63b5a809f3b83860972f0bef49bf28118f/native/YMC/Assets.xcassets/AppIcon.appiconset/16.png
--------------------------------------------------------------------------------
/native/YMC/Assets.xcassets/AppIcon.appiconset/256-1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Ty3uK/YMC/a4ae8f63b5a809f3b83860972f0bef49bf28118f/native/YMC/Assets.xcassets/AppIcon.appiconset/256-1.png
--------------------------------------------------------------------------------
/native/YMC/Assets.xcassets/AppIcon.appiconset/256.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Ty3uK/YMC/a4ae8f63b5a809f3b83860972f0bef49bf28118f/native/YMC/Assets.xcassets/AppIcon.appiconset/256.png
--------------------------------------------------------------------------------
/native/YMC/Assets.xcassets/AppIcon.appiconset/32-1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Ty3uK/YMC/a4ae8f63b5a809f3b83860972f0bef49bf28118f/native/YMC/Assets.xcassets/AppIcon.appiconset/32-1.png
--------------------------------------------------------------------------------
/native/YMC/Assets.xcassets/AppIcon.appiconset/32.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Ty3uK/YMC/a4ae8f63b5a809f3b83860972f0bef49bf28118f/native/YMC/Assets.xcassets/AppIcon.appiconset/32.png
--------------------------------------------------------------------------------
/native/YMC/Assets.xcassets/AppIcon.appiconset/512-1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Ty3uK/YMC/a4ae8f63b5a809f3b83860972f0bef49bf28118f/native/YMC/Assets.xcassets/AppIcon.appiconset/512-1.png
--------------------------------------------------------------------------------
/native/YMC/Assets.xcassets/AppIcon.appiconset/512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Ty3uK/YMC/a4ae8f63b5a809f3b83860972f0bef49bf28118f/native/YMC/Assets.xcassets/AppIcon.appiconset/512.png
--------------------------------------------------------------------------------
/native/YMC/Assets.xcassets/AppIcon.appiconset/64.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Ty3uK/YMC/a4ae8f63b5a809f3b83860972f0bef49bf28118f/native/YMC/Assets.xcassets/AppIcon.appiconset/64.png
--------------------------------------------------------------------------------
/native/YMC/Assets.xcassets/AppIcon.appiconset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "size" : "16x16",
5 | "idiom" : "mac",
6 | "filename" : "16.png",
7 | "scale" : "1x"
8 | },
9 | {
10 | "size" : "16x16",
11 | "idiom" : "mac",
12 | "filename" : "32.png",
13 | "scale" : "2x"
14 | },
15 | {
16 | "size" : "32x32",
17 | "idiom" : "mac",
18 | "filename" : "32-1.png",
19 | "scale" : "1x"
20 | },
21 | {
22 | "size" : "32x32",
23 | "idiom" : "mac",
24 | "filename" : "64.png",
25 | "scale" : "2x"
26 | },
27 | {
28 | "size" : "128x128",
29 | "idiom" : "mac",
30 | "filename" : "128.png",
31 | "scale" : "1x"
32 | },
33 | {
34 | "size" : "128x128",
35 | "idiom" : "mac",
36 | "filename" : "256.png",
37 | "scale" : "2x"
38 | },
39 | {
40 | "size" : "256x256",
41 | "idiom" : "mac",
42 | "filename" : "256-1.png",
43 | "scale" : "1x"
44 | },
45 | {
46 | "size" : "256x256",
47 | "idiom" : "mac",
48 | "filename" : "512.png",
49 | "scale" : "2x"
50 | },
51 | {
52 | "size" : "512x512",
53 | "idiom" : "mac",
54 | "filename" : "512-1.png",
55 | "scale" : "1x"
56 | },
57 | {
58 | "size" : "512x512",
59 | "idiom" : "mac",
60 | "filename" : "1024.png",
61 | "scale" : "2x"
62 | }
63 | ],
64 | "info" : {
65 | "version" : 1,
66 | "author" : "xcode"
67 | }
68 | }
--------------------------------------------------------------------------------
/native/YMC/Assets.xcassets/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "info" : {
3 | "version" : 1,
4 | "author" : "xcode"
5 | }
6 | }
--------------------------------------------------------------------------------
/native/YMC/Assets.xcassets/forward.imageset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "idiom" : "universal",
5 | "filename" : "fast-forward.pdf"
6 | }
7 | ],
8 | "info" : {
9 | "version" : 1,
10 | "author" : "xcode"
11 | },
12 | "properties" : {
13 | "compression-type" : "lossless",
14 | "template-rendering-intent" : "template",
15 | "preserves-vector-representation" : true
16 | }
17 | }
--------------------------------------------------------------------------------
/native/YMC/Assets.xcassets/forward.imageset/fast-forward.pdf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Ty3uK/YMC/a4ae8f63b5a809f3b83860972f0bef49bf28118f/native/YMC/Assets.xcassets/forward.imageset/fast-forward.pdf
--------------------------------------------------------------------------------
/native/YMC/Assets.xcassets/heart.imageset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "idiom" : "universal",
5 | "filename" : "heart.pdf"
6 | }
7 | ],
8 | "info" : {
9 | "version" : 1,
10 | "author" : "xcode"
11 | },
12 | "properties" : {
13 | "compression-type" : "lossless",
14 | "template-rendering-intent" : "template"
15 | }
16 | }
--------------------------------------------------------------------------------
/native/YMC/Assets.xcassets/heart.imageset/heart.pdf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Ty3uK/YMC/a4ae8f63b5a809f3b83860972f0bef49bf28118f/native/YMC/Assets.xcassets/heart.imageset/heart.pdf
--------------------------------------------------------------------------------
/native/YMC/Assets.xcassets/link.imageset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "idiom" : "universal",
5 | "filename" : "share.pdf"
6 | }
7 | ],
8 | "info" : {
9 | "version" : 1,
10 | "author" : "xcode"
11 | },
12 | "properties" : {
13 | "compression-type" : "lossless",
14 | "template-rendering-intent" : "template"
15 | }
16 | }
--------------------------------------------------------------------------------
/native/YMC/Assets.xcassets/link.imageset/share.pdf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Ty3uK/YMC/a4ae8f63b5a809f3b83860972f0bef49bf28118f/native/YMC/Assets.xcassets/link.imageset/share.pdf
--------------------------------------------------------------------------------
/native/YMC/Assets.xcassets/logo.imageset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "idiom" : "universal",
5 | "filename" : "logo.pdf"
6 | }
7 | ],
8 | "info" : {
9 | "version" : 1,
10 | "author" : "xcode"
11 | },
12 | "properties" : {
13 | "compression-type" : "lossless",
14 | "template-rendering-intent" : "original"
15 | }
16 | }
--------------------------------------------------------------------------------
/native/YMC/Assets.xcassets/logo.imageset/logo.pdf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Ty3uK/YMC/a4ae8f63b5a809f3b83860972f0bef49bf28118f/native/YMC/Assets.xcassets/logo.imageset/logo.pdf
--------------------------------------------------------------------------------
/native/YMC/Assets.xcassets/logo_large.imageset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "idiom" : "universal",
5 | "filename" : "logo_large.pdf"
6 | }
7 | ],
8 | "info" : {
9 | "version" : 1,
10 | "author" : "xcode"
11 | }
12 | }
--------------------------------------------------------------------------------
/native/YMC/Assets.xcassets/logo_large.imageset/logo_large.pdf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Ty3uK/YMC/a4ae8f63b5a809f3b83860972f0bef49bf28118f/native/YMC/Assets.xcassets/logo_large.imageset/logo_large.pdf
--------------------------------------------------------------------------------
/native/YMC/Assets.xcassets/pause.imageset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "idiom" : "universal",
5 | "filename" : "pause.pdf"
6 | }
7 | ],
8 | "info" : {
9 | "version" : 1,
10 | "author" : "xcode"
11 | },
12 | "properties" : {
13 | "compression-type" : "lossless",
14 | "template-rendering-intent" : "template",
15 | "preserves-vector-representation" : true
16 | }
17 | }
--------------------------------------------------------------------------------
/native/YMC/Assets.xcassets/pause.imageset/pause.pdf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Ty3uK/YMC/a4ae8f63b5a809f3b83860972f0bef49bf28118f/native/YMC/Assets.xcassets/pause.imageset/pause.pdf
--------------------------------------------------------------------------------
/native/YMC/Assets.xcassets/play.imageset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "idiom" : "universal",
5 | "filename" : "play.pdf"
6 | }
7 | ],
8 | "info" : {
9 | "version" : 1,
10 | "author" : "xcode"
11 | },
12 | "properties" : {
13 | "compression-type" : "lossless",
14 | "template-rendering-intent" : "template",
15 | "preserves-vector-representation" : true
16 | }
17 | }
--------------------------------------------------------------------------------
/native/YMC/Assets.xcassets/play.imageset/play.pdf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Ty3uK/YMC/a4ae8f63b5a809f3b83860972f0bef49bf28118f/native/YMC/Assets.xcassets/play.imageset/play.pdf
--------------------------------------------------------------------------------
/native/YMC/Assets.xcassets/refresh.imageset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "idiom" : "universal",
5 | "filename" : "refresh.pdf"
6 | }
7 | ],
8 | "info" : {
9 | "version" : 1,
10 | "author" : "xcode"
11 | },
12 | "properties" : {
13 | "compression-type" : "lossless",
14 | "template-rendering-intent" : "template"
15 | }
16 | }
--------------------------------------------------------------------------------
/native/YMC/Assets.xcassets/refresh.imageset/refresh.pdf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Ty3uK/YMC/a4ae8f63b5a809f3b83860972f0bef49bf28118f/native/YMC/Assets.xcassets/refresh.imageset/refresh.pdf
--------------------------------------------------------------------------------
/native/YMC/Assets.xcassets/rewind.imageset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "idiom" : "universal",
5 | "filename" : "rewind.pdf"
6 | }
7 | ],
8 | "info" : {
9 | "version" : 1,
10 | "author" : "xcode"
11 | },
12 | "properties" : {
13 | "compression-type" : "lossless",
14 | "template-rendering-intent" : "template",
15 | "preserves-vector-representation" : true
16 | }
17 | }
--------------------------------------------------------------------------------
/native/YMC/Assets.xcassets/rewind.imageset/rewind.pdf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Ty3uK/YMC/a4ae8f63b5a809f3b83860972f0bef49bf28118f/native/YMC/Assets.xcassets/rewind.imageset/rewind.pdf
--------------------------------------------------------------------------------
/native/YMC/Assets.xcassets/settings.imageset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "idiom" : "universal",
5 | "filename" : "settings.pdf"
6 | }
7 | ],
8 | "info" : {
9 | "version" : 1,
10 | "author" : "xcode"
11 | },
12 | "properties" : {
13 | "compression-type" : "lossless",
14 | "template-rendering-intent" : "template"
15 | }
16 | }
--------------------------------------------------------------------------------
/native/YMC/Assets.xcassets/settings.imageset/settings.pdf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Ty3uK/YMC/a4ae8f63b5a809f3b83860972f0bef49bf28118f/native/YMC/Assets.xcassets/settings.imageset/settings.pdf
--------------------------------------------------------------------------------
/native/YMC/Classes/Logger.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Logger.swift
3 | // YMC
4 | //
5 | // Created by Максим Карелов on 21/02/2019.
6 | // Copyright © 2019 Maksim Karelov. All rights reserved.
7 | //
8 |
9 | import Foundation
10 | import SwiftyBeaver
11 |
12 | enum LogLevel {
13 | case info
14 | case error
15 | case warning
16 | }
17 |
18 | class Logger {
19 | static var instance: Logger {
20 | get { return _instance }
21 | }
22 |
23 | var enabled: Bool {
24 | get { return UserDefaults.standard.bool(forKey: "debug") }
25 | }
26 |
27 | private static let _instance = Logger()
28 | private var logger: SwiftyBeaver.Type
29 |
30 | private init() {
31 | logger = SwiftyBeaver.self
32 |
33 | let file = FileDestination()
34 |
35 | file.logFileURL = URL(fileURLWithPath: "/tmp/ymc.log")
36 | logger.addDestination(file)
37 | }
38 |
39 | func log(logLevel: LogLevel, message: Any) {
40 | if !enabled { return }
41 |
42 | switch logLevel {
43 | case .info:
44 | logger.info(message)
45 | case .warning:
46 | logger.warning(message)
47 | case .error:
48 | logger.error(message)
49 | }
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/native/YMC/Classes/Manifest.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Manifest.swift
3 | // YMC
4 | //
5 | // Created by Максим Карелов on 20/02/2019.
6 | // Copyright © 2019 Maksim Karelov. All rights reserved.
7 | //
8 |
9 | import Foundation
10 | import Files
11 |
12 | enum WriteManifestStatus {
13 | case updated
14 | case skipped
15 | case error
16 | }
17 |
18 | struct Manifest: Codable, Equatable {
19 | let name: String
20 | let description: String
21 | let path: String
22 | let type: String
23 | var allowed_extensions: [String]? = nil
24 | var allowed_origins: [String]? = nil
25 |
26 | init(name: String, description: String, path: String, type: String) {
27 | self.name = name
28 | self.description = description
29 | self.path = path
30 | self.type = type
31 | }
32 |
33 | static func == (a: Manifest, b: Manifest) -> Bool {
34 | return (
35 | a.name == b.name &&
36 | a.description == b.description &&
37 | a.path == b.path &&
38 | a.type == b.type &&
39 | (
40 | (a.allowed_extensions != nil && b.allowed_extensions != nil && a.allowed_extensions!.elementsEqual(b.allowed_extensions!)) ||
41 | (a.allowed_origins != nil && b.allowed_origins != nil && a.allowed_origins!.elementsEqual(b.allowed_origins!))
42 | )
43 | )
44 | }
45 | }
46 |
47 | private let paths = [
48 | "\(Folder.home.path)Library/Application Support/Mozilla",
49 | "\(Folder.home.path)Library/Application Support/Google/Chrome",
50 | "\(Folder.home.path)Library/Application Support/Chromium",
51 | "\(Folder.home.path)Library/Application Support/com.operasoftware.Opera",
52 | "\(Folder.home.path)Library/Application Support/Yandex/YandexBrowser",
53 | "\(Folder.home.path)Library/Application Support/Vivaldi"
54 | ]
55 |
56 | private func createManifest(appPath: String, isChromeBasedBrowser: Bool) -> Manifest {
57 | var manifest = Manifest(
58 | name: "ymc",
59 | description: "Control Yandex.Music from any window in MacOS",
60 | path: appPath,
61 | type: "stdio"
62 | )
63 |
64 | if isChromeBasedBrowser {
65 | manifest.allowed_origins = ["chrome-extension://mhjachgjcpppedbkfaajhodfgiilbmci/"]
66 | } else {
67 | manifest.allowed_extensions = ["ymc@karelov.info"]
68 | }
69 |
70 | return manifest
71 | }
72 |
73 | private func writeManifest(_ path: String) -> WriteManifestStatus {
74 | guard let appPath = Bundle.main.executablePath else { return .error }
75 |
76 | let manifest = createManifest(appPath: appPath, isChromeBasedBrowser: !path.contains("Mozilla"))
77 | let encoder = JSONEncoder()
78 |
79 | encoder.outputFormatting = .prettyPrinted
80 |
81 | guard let manifestData = try? encoder.encode(manifest) else { return .error }
82 | guard let manifestDataString = String(data: manifestData, encoding: .utf8)?
83 | .replacingOccurrences(of: "\\/", with: "/")
84 | .replacingOccurrences(of: " : ", with: ": ") else { return .error }
85 | guard let manifestDataFixed = manifestDataString.data(using: .utf8) else { return .error }
86 | guard let browserFolder = try? Folder(path: path) else { return .skipped }
87 |
88 | var folder: Folder
89 |
90 | if let targetFolder = try? browserFolder.subfolder(named: "NativeMessagingHosts") {
91 | folder = targetFolder
92 | } else if let targetFolder = try? browserFolder.createSubfolder(named: "NativeMessagingHosts") {
93 | folder = targetFolder
94 | } else {
95 | return .skipped
96 | }
97 |
98 | var targetFile: File?
99 |
100 | if let file = try? folder.file(named: "ymc.json") {
101 | guard let existingManifestData = try? file.read() else { return .error }
102 |
103 | if let existingManifest = try? JSONDecoder().decode(Manifest.self, from: existingManifestData), existingManifest == manifest {
104 | return .skipped
105 | }
106 |
107 | targetFile = file
108 | } else {
109 | targetFile = try? folder.createFile(named: "ymc.json")
110 | }
111 |
112 | if targetFile == nil {
113 | return .error
114 | }
115 |
116 | do {
117 | try targetFile!.write(data: manifestDataFixed)
118 | } catch {
119 | return .error
120 | }
121 |
122 | return .updated
123 | }
124 |
125 | func processManifests() -> WriteManifestStatus {
126 | let results = paths.map { writeManifest($0) }
127 |
128 | if results.contains(.error) {
129 | return .error
130 | } else if results.contains(.updated) {
131 | return .updated
132 | }
133 |
134 | return .skipped
135 | }
136 |
--------------------------------------------------------------------------------
/native/YMC/Classes/Messaging.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Messaging.swift
3 | // YMC
4 | //
5 | // Created by Максим Карелов on 26/01/2019.
6 | // Copyright © 2019 Maksim Karelov. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | struct OutgoingMessage: Encodable {
12 | let type: String
13 | }
14 |
15 | struct EncodedOutgoingMessage {
16 | let content: String
17 | let length: String
18 | }
19 |
20 | struct IncomingMessage: Decodable {
21 | let type: String
22 | let data: Any?
23 |
24 | private enum CodingKeys: String, CodingKey {
25 | case type
26 | case data
27 | }
28 |
29 | private typealias IncomingMessageDataDecoder = (KeyedDecodingContainer) throws -> Any
30 |
31 | private static var decoders: [String: IncomingMessageDataDecoder] = [:]
32 |
33 | static func register(_ type: A.Type, for typeName: String) {
34 | decoders[typeName] = { container in
35 | try container.decode(A.self, forKey: .data)
36 | }
37 | }
38 |
39 | init(from decoder: Decoder) throws {
40 | let container = try decoder.container(keyedBy: CodingKeys.self)
41 | type = try container.decode(String.self, forKey: .type)
42 |
43 | if let decode = IncomingMessage.decoders[type] {
44 | data = try decode(container)
45 | } else {
46 | data = nil
47 | }
48 | }
49 | }
50 |
51 | extension FileHandle : TextOutputStream {
52 | public func write(_ string: String) {
53 | guard let data = string.data(using: .utf8) else { return }
54 | self.write(data)
55 | }
56 | }
57 |
58 | private func encodeMessage(_ message: OutgoingMessage) -> EncodedOutgoingMessage? {
59 | guard let json = try? JSONEncoder().encode(message),
60 | let jsonString = String(data: json, encoding: .utf8),
61 | let lengthString = String(data: pack("=I", [json.count], .utf8), encoding: .utf8)
62 | else {
63 | return nil
64 | }
65 |
66 | return EncodedOutgoingMessage(
67 | content: jsonString,
68 | length: lengthString
69 | )
70 | }
71 |
72 | private func writeEncodedMessage(_ message: EncodedOutgoingMessage) {
73 | var standardOutput = FileHandle.standardOutput
74 | print(message.length, separator: "", terminator: "", to: &standardOutput)
75 | print(message.content, separator: "", terminator: "", to: &standardOutput)
76 | fflush(__stdoutp)
77 | }
78 |
79 | func writeMessage(_ messageType: String) {
80 | if let encodedMessage = encodeMessage(OutgoingMessage(type: messageType)) {
81 | writeEncodedMessage(encodedMessage)
82 | }
83 | }
84 |
--------------------------------------------------------------------------------
/native/YMC/Classes/Player.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Player.swift
3 | // YMC
4 | //
5 | // Created by Максим Карелов on 15/02/2019.
6 | // Copyright © 2019 Maksim Karelov. All rights reserved.
7 | //
8 |
9 | import Cocoa
10 | import Foundation
11 | import RxSwift
12 |
13 | enum PlayerControlsAvailability {
14 | case PREV
15 | case NEXT
16 | case LIKE
17 | }
18 |
19 | enum PlayerButtons {
20 | case PREV
21 | case PLAY_PAUSE
22 | case NEXT
23 | case LINK
24 | case LIKE
25 | case TRACK_INFO
26 | case REFRESH
27 | case NOOP
28 | }
29 |
30 | struct CurrentTrack: Decodable {
31 | let title: String
32 | let artist: String
33 | let cover: URL?
34 | let liked: Bool
35 | let link: URL?
36 | }
37 |
38 | struct Controls: Decodable {
39 | let next: Bool?
40 | let prev: Bool?
41 | let like: Bool?
42 | }
43 |
44 | struct IsPlaying: Decodable {
45 | let state: Bool
46 | }
47 |
48 | struct IsLiked: Decodable {
49 | let state: Bool
50 | }
51 |
52 | struct PlayerState: Decodable {
53 | let currentTrack: CurrentTrack
54 | let controls: Controls
55 | let isPlaying: Bool
56 | }
57 |
58 | class Player {
59 | private static let _instance = Player()
60 |
61 | static var instance: Player {
62 | get { return _instance }
63 | }
64 |
65 | private let _availableControls$ = BehaviorSubject(value: Controls(
66 | next: true,
67 | prev: false,
68 | like: false
69 | ))
70 | private let _playingState$ = BehaviorSubject(value: false)
71 | private let _currentTrack$ = BehaviorSubject(value: CurrentTrack(
72 | title: "No title",
73 | artist: "No artist",
74 | cover: nil,
75 | liked: false,
76 | link: nil
77 | ))
78 | private let _buttonPress$ = BehaviorSubject(value: PlayerButtons.NOOP)
79 | private let _coverImage$ = BehaviorSubject(value: NSImage(named: "logo_large"))
80 |
81 | public var availableControls$: Observable
82 | public var playingState$: Observable
83 | public var currentTrack$: Observable
84 | public var buttonPress$: Observable
85 | public var coverImage$: Observable
86 |
87 | public var currentTrack: CurrentTrack? {
88 | get {
89 | guard let result = try? _currentTrack$.value() else { return nil }
90 | return result
91 | }
92 | }
93 | public var coverImage: NSImage? {
94 | get {
95 | guard let result = ((try? _coverImage$.value()) as NSImage??) else { return nil }
96 | return result
97 | }
98 | }
99 |
100 | private init() {
101 | availableControls$ = _availableControls$.asObservable()
102 | playingState$ = _playingState$.asObservable()
103 | currentTrack$ = _currentTrack$.asObservable()
104 | buttonPress$ = _buttonPress$.asObservable()
105 | coverImage$ = _coverImage$.asObservable()
106 | }
107 |
108 | public func changeControls(_ controls: Controls) {
109 | _availableControls$.onNext(controls)
110 | }
111 |
112 | public func changePlayingState(_ state: Bool) {
113 | _playingState$.onNext(state)
114 | }
115 |
116 | public func changeCurrentTrack(_ state: CurrentTrack) {
117 | _currentTrack$.onNext(state)
118 | }
119 |
120 | public func changeLikedState(_ state: Bool) {
121 | guard let currentTrack = try? _currentTrack$.value() else { return }
122 |
123 | let newCurrentTrack = CurrentTrack(
124 | title: currentTrack.title,
125 | artist: currentTrack.artist,
126 | cover: currentTrack.cover,
127 | liked: state,
128 | link: currentTrack.link
129 | )
130 |
131 | _currentTrack$.onNext(newCurrentTrack)
132 | }
133 |
134 | public func emitPressedButton(_ button: PlayerButtons) {
135 | _buttonPress$.onNext(button)
136 | }
137 |
138 | public func changeCoverImage(_ url: URL?) {
139 | guard let url = url else {
140 | _coverImage$.onNext(NSImage(named: "logo_large"))
141 | return
142 | }
143 |
144 | URLSession.shared.dataTask(with: url, completionHandler: { data, response, error in
145 | guard let data = data, error == nil else { return }
146 | self._coverImage$.onNext(NSImage(data: data))
147 | }).resume()
148 | }
149 | }
150 |
--------------------------------------------------------------------------------
/native/YMC/Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | CFBundleDevelopmentRegion
6 | $(DEVELOPMENT_LANGUAGE)
7 | CFBundleExecutable
8 | $(EXECUTABLE_NAME)
9 | CFBundleIconFile
10 |
11 | CFBundleIdentifier
12 | $(PRODUCT_BUNDLE_IDENTIFIER)
13 | CFBundleInfoDictionaryVersion
14 | 6.0
15 | CFBundleName
16 | $(PRODUCT_NAME)
17 | CFBundlePackageType
18 | APPL
19 | CFBundleShortVersionString
20 | 1.0.4
21 | CFBundleURLTypes
22 |
23 |
24 | CFBundleURLName
25 | info.karelov.YMC
26 | CFBundleURLSchemes
27 |
28 | ymc
29 |
30 |
31 |
32 | CFBundleVersion
33 | 5
34 | LSMinimumSystemVersion
35 | $(MACOSX_DEPLOYMENT_TARGET)
36 | LSUIElement
37 |
38 | NSAppTransportSecurity
39 |
40 | NSAllowsArbitraryLoads
41 |
42 |
43 | NSHumanReadableCopyright
44 | Copyright © 2019 Maksim Karelov. All rights reserved.
45 | NSMainStoryboardFile
46 | Main
47 | NSPrincipalClass
48 | NSApplication
49 |
50 |
51 |
--------------------------------------------------------------------------------
/native/YMC/Vendor/BinUtils.swift:
--------------------------------------------------------------------------------
1 | //
2 | // BinUtils.swift
3 | // BinUtils
4 | //
5 | // Created by Nicolas Seriot on 12/03/16.
6 | // Copyright © 2016 Nicolas Seriot. All rights reserved.
7 | //
8 |
9 | import Foundation
10 | import CoreFoundation
11 |
12 | // MARK: protocol UnpackedType
13 |
14 | public protocol Unpackable {}
15 |
16 | extension NSString: Unpackable {}
17 | extension Bool: Unpackable {}
18 | extension Int: Unpackable {}
19 | extension Double: Unpackable {}
20 |
21 | // MARK: protocol DataConvertible
22 |
23 | protocol DataConvertible {}
24 |
25 | extension DataConvertible {
26 |
27 | init?(data: Data) {
28 | guard data.count == MemoryLayout.size else { return nil }
29 | self = data.withUnsafeBytes { $0.pointee }
30 | }
31 |
32 | init?(bytes: [UInt8]) {
33 | let data = Data(bytes:bytes)
34 | self.init(data:data)
35 | }
36 |
37 | var data: Data {
38 | var value = self
39 | return Data(buffer: UnsafeBufferPointer(start: &value, count: 1))
40 | }
41 | }
42 |
43 | extension Bool : DataConvertible { }
44 |
45 | extension Int8 : DataConvertible { }
46 | extension Int16 : DataConvertible { }
47 | extension Int32 : DataConvertible { }
48 | extension Int64 : DataConvertible { }
49 |
50 | extension UInt8 : DataConvertible { }
51 | extension UInt16 : DataConvertible { }
52 | extension UInt32 : DataConvertible { }
53 | extension UInt64 : DataConvertible { }
54 |
55 | extension Float32 : DataConvertible { }
56 | extension Float64 : DataConvertible { }
57 |
58 | // MARK: String extension
59 |
60 | extension String {
61 | subscript (from:Int, to:Int) -> String {
62 | return NSString(string: self).substring(with: NSMakeRange(from, to-from))
63 | }
64 | }
65 |
66 | // MARK: Data extension
67 |
68 | extension Data {
69 | var bytes : [UInt8] {
70 | return self.withUnsafeBytes {
71 | [UInt8](UnsafeBufferPointer(start: $0, count: self.count))
72 | }
73 | }
74 | }
75 |
76 | // MARK: functions
77 |
78 | public func hexlify(_ data:Data) -> String {
79 |
80 | // similar to hexlify() in Python's binascii module
81 | // https://docs.python.org/2/library/binascii.html
82 |
83 | var s = String()
84 | var byte: UInt8 = 0
85 |
86 | for i in 0 ..< data.count {
87 | NSData(data: data).getBytes(&byte, range: NSMakeRange(i, 1))
88 | s = s.appendingFormat("%02x", byte)
89 | }
90 |
91 | return s as String
92 | }
93 |
94 | public func unhexlify(_ string:String) -> Data? {
95 |
96 | // similar to unhexlify() in Python's binascii module
97 | // https://docs.python.org/2/library/binascii.html
98 |
99 | let s = string.uppercased().replacingOccurrences(of: " ", with: "")
100 |
101 | let nonHexCharacterSet = CharacterSet(charactersIn: "0123456789ABCDEF").inverted
102 | if let range = s.rangeOfCharacter(from: nonHexCharacterSet) {
103 | print("-- found non hex character at range \(range)")
104 | return nil
105 | }
106 |
107 | var data = Data(capacity: s.count / 2)
108 |
109 | for i in stride(from: 0, to:s.count, by:2) {
110 | let byteString = s[i, i+2]
111 | let byte = UInt8(byteString.withCString { strtoul($0, nil, 16) })
112 | data.append([byte] as [UInt8], count: 1)
113 | }
114 |
115 | return data
116 | }
117 |
118 | func readIntegerType(_ type:T.Type, bytes:[UInt8], loc:inout Int) -> T {
119 | let size = MemoryLayout.size
120 | let sub = Array(bytes[loc..<(loc+size)])
121 | loc += size
122 | return T(bytes: sub)!
123 | }
124 |
125 | func readFloatingPointType(_ type:T.Type, bytes:[UInt8], loc:inout Int, isBigEndian:Bool) -> T {
126 | let size = MemoryLayout.size
127 | let sub = Array(bytes[loc..<(loc+size)])
128 | loc += size
129 | let sub_ = isBigEndian ? sub.reversed() : sub
130 | return T(bytes: sub_)!
131 | }
132 |
133 | func isBigEndianFromMandatoryByteOrderFirstCharacter(_ format:String) -> Bool {
134 |
135 | guard let firstChar = format.first else { assertionFailure("empty format"); return false }
136 |
137 | let s = NSString(string: String(firstChar))
138 | let c = s.substring(to: 1)
139 |
140 | if c == "@" { assertionFailure("native size and alignment is unsupported") }
141 |
142 | if c == "=" || c == "<" { return false }
143 | if c == ">" || c == "!" { return true }
144 |
145 | assertionFailure("format '\(format)' first character must be among '=<>!'")
146 |
147 | return false
148 | }
149 |
150 | // akin to struct.calcsize(fmt)
151 | func numberOfBytesInFormat(_ format:String) -> Int {
152 |
153 | var numberOfBytes = 0
154 |
155 | var n = 0 // repeat counter
156 |
157 | var mutableFormat = format
158 |
159 | while !mutableFormat.isEmpty {
160 |
161 | let c = mutableFormat.remove(at: mutableFormat.startIndex)
162 |
163 | if let i = Int(String(c)) , 0...9 ~= i {
164 | if n > 0 { n *= 10 }
165 | n += i
166 | continue
167 | }
168 |
169 | if c == "s" {
170 | numberOfBytes += max(n,1)
171 | n = 0
172 | continue
173 | }
174 |
175 | let repeatCount = max(n,1)
176 |
177 | switch(c) {
178 |
179 | case "@", "<", "=", ">", "!", " ":
180 | ()
181 | case "c", "b", "B", "x", "?":
182 | numberOfBytes += 1 * repeatCount
183 | case "h", "H":
184 | numberOfBytes += 2 * repeatCount
185 | case "i", "l", "I", "L", "f":
186 | numberOfBytes += 4 * repeatCount
187 | case "q", "Q", "d":
188 | numberOfBytes += 8 * repeatCount
189 | case "P":
190 | numberOfBytes += MemoryLayout.size * repeatCount
191 | default:
192 | assertionFailure("-- unsupported format \(c)")
193 | }
194 |
195 | n = 0
196 | }
197 |
198 | return numberOfBytes
199 | }
200 |
201 | func formatDoesMatchDataLength(_ format:String, data:Data) -> Bool {
202 | let sizeAccordingToFormat = numberOfBytesInFormat(format)
203 | let dataLength = data.count
204 | if sizeAccordingToFormat != dataLength {
205 | print("format \"\(format)\" expects \(sizeAccordingToFormat) bytes but data is \(dataLength) bytes")
206 | return false
207 | }
208 |
209 | return true
210 | }
211 |
212 | /*
213 | pack() and unpack() should behave as Python's struct module https://docs.python.org/2/library/struct.html BUT:
214 | - native size and alignment '@' is not supported
215 | - as a consequence, the byte order specifier character is mandatory and must be among "=<>!"
216 | - native byte order '=' assumes a little-endian system (eg. Intel x86)
217 | - Pascal strings 'p' and native pointers 'P' are not supported
218 | */
219 |
220 | public enum BinUtilsError: Error {
221 | case formatDoesMatchDataLength(format:String, dataSize:Int)
222 | case unsupportedFormat(character:Character)
223 | }
224 |
225 | public func pack(_ format:String, _ objects:[Any], _ stringEncoding:String.Encoding=String.Encoding.windowsCP1252) -> Data {
226 |
227 | var objectsQueue = objects
228 |
229 | var mutableFormat = format
230 |
231 | var mutableData = Data()
232 |
233 | var isBigEndian = false
234 |
235 | let firstCharacter = mutableFormat.remove(at: mutableFormat.startIndex)
236 |
237 | switch(firstCharacter) {
238 | case "<", "=":
239 | isBigEndian = false
240 | case ">", "!":
241 | isBigEndian = true
242 | case "@":
243 | assertionFailure("native size and alignment '@' is unsupported'")
244 | default:
245 | assertionFailure("unsupported format chacracter'")
246 | }
247 |
248 | var n = 0 // repeat counter
249 |
250 | while !mutableFormat.isEmpty {
251 |
252 | let c = mutableFormat.remove(at: mutableFormat.startIndex)
253 |
254 | if let i = Int(String(c)) , 0...9 ~= i {
255 | if n > 0 { n *= 10 }
256 | n += i
257 | continue
258 | }
259 |
260 | var o : Any = 0
261 |
262 | if c == "s" {
263 | o = objectsQueue.remove(at: 0)
264 |
265 | guard let stringData = (o as! String).data(using: .utf8) else { assertionFailure(); return Data() }
266 | var bytes = stringData.bytes
267 |
268 | let expectedSize = max(1, n)
269 |
270 | // pad ...
271 | while bytes.count < expectedSize { bytes.append(0x00) }
272 |
273 | // ... or trunk
274 | if bytes.count > expectedSize { bytes = Array(bytes[0.. [Unpackable] {
340 |
341 | assert(CFByteOrderGetCurrent() == 1 /* CFByteOrderLittleEndian */, "\(#file) assumes little endian, but host is big endian")
342 |
343 | let isBigEndian = isBigEndianFromMandatoryByteOrderFirstCharacter(format)
344 |
345 | if formatDoesMatchDataLength(format, data: data) == false {
346 | throw BinUtilsError.formatDoesMatchDataLength(format:format, dataSize:data.count)
347 | }
348 |
349 | var a : [Unpackable] = []
350 |
351 | var loc = 0
352 |
353 | let bytes = data.bytes
354 |
355 | var n = 0 // repeat counter
356 |
357 | var mutableFormat = format
358 |
359 | mutableFormat.remove(at: mutableFormat.startIndex) // consume byte-order specifier
360 |
361 | while !mutableFormat.isEmpty {
362 |
363 | let c = mutableFormat.remove(at: mutableFormat.startIndex)
364 |
365 | if let i = Int(String(c)) , 0...9 ~= i {
366 | if n > 0 { n *= 10 }
367 | n += i
368 | continue
369 | }
370 |
371 | if c == "s" {
372 | let length = max(n,1)
373 | let sub = Array(bytes[loc..
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------