diff --git a/yetanotherradio@io.github.buddysirjava/extension.js b/yetanotherradio@io.github.buddysirjava/extension.js index 8bcee53..5abb2fe 100644 --- a/yetanotherradio@io.github.buddysirjava/extension.js +++ b/yetanotherradio@io.github.buddysirjava/extension.js @@ -12,16 +12,17 @@ import { createMetadataItem, updateMetadataDisplay, updatePlaybackStateIcon } fr import { createVolumeItem, onVolumeChanged } from './modules/volumeControl.js'; import { createScrollableSection, createStationMenuItem, refreshStationsMenu } from './modules/stationMenu.js'; import PlaybackManager from './modules/playbackManager.js'; -import { setupMediaKeys, cleanupMediaKeys } from './modules/mediaKeys.js'; +import MprisInterface from './modules/mprisInterface.js'; const Indicator = GObject.registerClass( class Indicator extends PanelMenu.Button { - _init(stations, openPrefs, extensionPath, settings) { + _init(stations, openPrefs, extensionPath, settings, onStationsChanged) { super._init(0.0, _('Yet Another Radio')); this._stations = stations ?? []; this._openPrefs = openPrefs; this._settings = settings; + this._onStationsChanged = onStationsChanged; this._refreshIdleId = 0; const iconPath = `${extensionPath}/icons/yetanotherradio.svg`; @@ -125,6 +126,7 @@ const Indicator = GObject.registerClass( setStations(stations) { this._stations = stations ?? []; this._refreshStationsMenu(); + this._onStationsChanged?.(this._stations.length); } _refreshStationsMenu() { @@ -168,12 +170,24 @@ const Indicator = GObject.registerClass( this._playbackManager.stop(); } - handleMediaPlayPause() { - this._togglePlayback(); + _orderedStations() { + const favorites = this._stations + .filter(s => s.favorite) + .sort((a, b) => a.name.localeCompare(b.name)); + const regulars = this._stations.filter(s => !s.favorite); + return [...favorites, ...regulars]; } - handleMediaStop() { - this._stopPlayback(); + navigateStation(delta) { + if (!this._playbackManager.nowPlaying) return; + const ordered = this._orderedStations(); + if (ordered.length <= 1) return; + const currentIdx = ordered.findIndex( + s => s.uuid === this._playbackManager.nowPlaying.uuid + ); + if (currentIdx === -1) return; + const nextIdx = (currentIdx + delta + ordered.length) % ordered.length; + this._playStation(ordered[nextIdx]); } destroy() { @@ -201,7 +215,54 @@ export default class YetAnotherRadioExtension extends Extension { initTranslations(_); ensureStorageFile(); this._settings = this.getSettings(); - this._indicator = new Indicator([], () => this.openPreferences(), this.path, this._settings); + this._indicator = new Indicator( + [], + () => this.openPreferences(), + this.path, + this._settings, + (count) => this._mpris?.setStationCount(count) + ); + + if (this._settings.get_boolean('enable-mpris')) { + try { + this._mpris = new MprisInterface( + this._indicator._playbackManager, + this._settings, + (delta) => this._indicator.navigateStation(delta), + () => this._indicator._stations.slice().sort((a, b) => (b.lastPlayed ?? 0) - (a.lastPlayed ?? 0))[0] ?? null, + () => this._indicator.menu.open(true) + ); + this._mpris.setStationCount(this._indicator._stations.length); + } catch (error) { + console.warn('Failed to initialize MPRIS interface:', error); + } + } + + this._mprisSettingId = 0; + this._mprisSettingId = this._settings.connect('changed::enable-mpris', () => { + if (this._settings.get_boolean('enable-mpris')) { + if (!this._mpris) { + try { + this._mpris = new MprisInterface( + this._indicator._playbackManager, + this._settings, + (delta) => this._indicator.navigateStation(delta), + () => this._indicator._stations.slice().sort((a, b) => (b.lastPlayed ?? 0) - (a.lastPlayed ?? 0))[0] ?? null, + () => this._indicator.menu.open(true) + ); + this._mpris.setStationCount(this._indicator._stations.length); + } catch (error) { + console.warn('Failed to initialize MPRIS interface:', error); + } + } + } else { + if (this._mpris) { + this._mpris.destroy(); + this._mpris = null; + } + } + }); + Main.panel.addToStatusArea(this.uuid, this._indicator); loadStations().then(stations => { @@ -213,7 +274,6 @@ export default class YetAnotherRadioExtension extends Extension { }); this._monitor = this._watchStationsFile(); - this._setupMediaKeys(); } _watchStationsFile() { @@ -229,24 +289,6 @@ export default class YetAnotherRadioExtension extends Extension { return monitor; } - _setupMediaKeys() { - const { mediaKeyAccelerators, acceleratorHandlerId, mediaKeysSettingsHandlerId } = setupMediaKeys(this._settings, this._indicator); - this._mediaKeyAccelerators = mediaKeyAccelerators; - this._acceleratorHandlerId = acceleratorHandlerId; - this._mediaKeysSettingsHandlerId = mediaKeysSettingsHandlerId; - this._mediaKeysSettingsHandlerId = this._settings?.connect('changed::enable-media-keys', () => { - this._cleanupMediaKeys(); - this._setupMediaKeys(); - }); - } - - _cleanupMediaKeys() { - const { mediaKeyAccelerators, acceleratorHandlerId, mediaKeysSettingsHandlerId } = cleanupMediaKeys(this._mediaKeyAccelerators, this._acceleratorHandlerId, this._mediaKeysSettingsHandlerId, this._settings); - this._mediaKeyAccelerators = mediaKeyAccelerators; - this._acceleratorHandlerId = acceleratorHandlerId; - this._mediaKeysSettingsHandlerId = mediaKeysSettingsHandlerId; - } - disable() { if (this._monitor) { if (this._monitorHandlerId) { @@ -257,7 +299,15 @@ export default class YetAnotherRadioExtension extends Extension { this._monitor = null; } - this._cleanupMediaKeys(); + if (this._mprisSettingId) { + this._settings.disconnect(this._mprisSettingId); + this._mprisSettingId = 0; + } + + if (this._mpris) { + this._mpris.destroy(); + this._mpris = null; + } this._indicator?.destroy(); this._indicator = null; diff --git a/yetanotherradio@io.github.buddysirjava/modules/mediaKeys.js b/yetanotherradio@io.github.buddysirjava/modules/mediaKeys.js deleted file mode 100644 index a022f51..0000000 --- a/yetanotherradio@io.github.buddysirjava/modules/mediaKeys.js +++ /dev/null @@ -1,67 +0,0 @@ -import Meta from 'gi://Meta'; - -export function setupMediaKeys(settings, indicator) { - const enableMediaKeys = settings?.get_boolean('enable-media-keys') ?? true; - if (!enableMediaKeys) { - return { mediaKeyAccelerators: [], acceleratorHandlerId: null, mediaKeysSettingsHandlerId: null }; - } - - const display = global.display; - const mediaKeyAccelerators = []; - - const playPauseId = display.grab_accelerator('XF86AudioPlay', Meta.KeyBindingFlags.NONE); - if (playPauseId > 0) { - mediaKeyAccelerators.push({ - id: playPauseId, - action: 'play-pause' - }); - } - - const stopId = display.grab_accelerator('XF86AudioStop', Meta.KeyBindingFlags.NONE); - if (stopId > 0) { - mediaKeyAccelerators.push({ - id: stopId, - action: 'stop' - }); - } - - const acceleratorHandlerId = global.display.connect('accelerator-activated', (display, action, deviceId, timestamp) => { - const accelerator = mediaKeyAccelerators.find(acc => acc.id === action); - if (!accelerator || !indicator) { - return; - } - - if (accelerator.action === 'play-pause') { - indicator.handleMediaPlayPause(); - } else if (accelerator.action === 'stop') { - indicator.handleMediaStop(); - } - }); - - return { mediaKeyAccelerators, acceleratorHandlerId, mediaKeysSettingsHandlerId: null }; -} - -export function cleanupMediaKeys(mediaKeyAccelerators, acceleratorHandlerId, mediaKeysSettingsHandlerId, settings) { - if (acceleratorHandlerId) { - global.display.disconnect(acceleratorHandlerId); - acceleratorHandlerId = null; - } - - if (mediaKeyAccelerators) { - const display = global.display; - mediaKeyAccelerators.forEach(acc => { - try { - display.ungrab_accelerator(acc.id); - } catch (e) { - console.debug(e); - } - }); - } - - if (mediaKeysSettingsHandlerId) { - settings?.disconnect(mediaKeysSettingsHandlerId); - mediaKeysSettingsHandlerId = null; - } - - return { mediaKeyAccelerators: [], acceleratorHandlerId, mediaKeysSettingsHandlerId }; -} diff --git a/yetanotherradio@io.github.buddysirjava/modules/mprisInterface.js b/yetanotherradio@io.github.buddysirjava/modules/mprisInterface.js new file mode 100644 index 0000000..5641b02 --- /dev/null +++ b/yetanotherradio@io.github.buddysirjava/modules/mprisInterface.js @@ -0,0 +1,275 @@ +import Gio from 'gi://Gio'; +import GLib from 'gi://GLib'; + +const MPRIS_SERVICE_NAME = 'org.mpris.MediaPlayer2.yetanotherradio'; +const MPRIS_OBJECT_PATH = '/org/mpris/MediaPlayer2'; + +const MPRIS_ROOT_XML = ` + + + + + + + +`; + +const MPRIS_PLAYER_XML = ` + + + + + + + + + + + + + + + + +`; + +export default class MprisInterface { + constructor(playbackManager, settings, navigateCallback, lastStationCallback, raiseCallback) { + this._playbackManager = playbackManager; + this._settings = settings; + this._navigateCallback = navigateCallback ?? null; + this._lastStationCallback = lastStationCallback ?? null; + this._raiseCallback = raiseCallback ?? null; + this._stationCount = 0; + this._dbusConnection = null; + this._rootExported = null; + this._playerExported = null; + this._ownerId = 0; + this._settingsChangedId = 0; + + this._setupCallbacks(); + this._setupSettingsMonitoring(); + this._register(); + } + + _setupCallbacks() { + if (!this._playbackManager) return; + + this._onState = () => this._emitPlayerPropertiesChanged(['PlaybackStatus', 'CanPlay', 'CanPause']); + this._onMeta = () => this._emitPlayerPropertiesChanged(['Metadata']); + this._onStation = () => this._emitPlayerPropertiesChanged(['Metadata', 'CanPlay', 'CanPause', 'CanGoNext', 'CanGoPrevious']); + + this._playbackManager.addListener('onStateChanged', this._onState); + this._playbackManager.addListener('onMetadataUpdate', this._onMeta); + this._playbackManager.addListener('onStationChanged', this._onStation); + } + + _setupSettingsMonitoring() { + if (this._settings) { + this._settingsChangedId = this._settings.connect('changed::volume', () => { + this._emitPlayerPropertiesChanged(['Volume']); + }); + } + } + + _register() { + try { + this._dbusConnection = Gio.bus_get_sync(Gio.BusType.SESSION, null); + + const self = this; + + this._rootExported = Gio.DBusExportedObject.wrapJSObject( + MPRIS_ROOT_XML, + { + Raise() { self._raiseCallback?.(); }, + get Identity() { return 'Yet Another Radio'; }, + get CanQuit() { return false; }, + get CanRaise() { return true; }, + get HasTrackList() { return false; }, + } + ); + + this._playerExported = Gio.DBusExportedObject.wrapJSObject( + MPRIS_PLAYER_XML, + { + Play() { + const m = self._playbackManager; + if (!m) return; + const target = m.nowPlaying ?? self._lastStationCallback?.(); + if (target) m.play(target); + }, + Pause() { const m = self._playbackManager; if (m?.playbackState === 'playing') m.toggle(); }, + PlayPause() { + const m = self._playbackManager; + if (!m) return; + if (m.playbackState === 'stopped') { + const target = m.nowPlaying ?? self._lastStationCallback?.(); + if (target) m.play(target); + } else { + m.toggle(); + } + }, + Stop() { self._playbackManager?.stop(); }, + Next() { self._navigateCallback?.(+1); }, + Previous() { self._navigateCallback?.(-1); }, + get PlaybackStatus() { return self._getPlaybackStatus(); }, + get Metadata() { return self._getMetadata(); }, + get Volume() { return self._getVolume(); }, + set Volume(v) { + const vol = Math.max(0.0, Math.min(1.0, v)); + self._playbackManager?.setVolume(vol); + self._settings?.set_int('volume', Math.round(vol * 100)); + }, + get CanPlay() { return !!self._playbackManager?.nowPlaying; }, + get CanPause() { return !!self._playbackManager?.nowPlaying && self._getPlaybackStatus() !== 'Stopped'; }, + get CanControl() { return true; }, + get CanGoNext() { return self._canNavigate(); }, + get CanGoPrevious() { return self._canNavigate(); }, + } + ); + + this._rootExported.export(this._dbusConnection, MPRIS_OBJECT_PATH); + this._playerExported.export(this._dbusConnection, MPRIS_OBJECT_PATH); + + this._ownerId = Gio.bus_own_name_on_connection( + this._dbusConnection, + MPRIS_SERVICE_NAME, + Gio.BusNameOwnerFlags.NONE, + null, + null + ); + + if (this._ownerId === 0) { + console.error('MPRIS: Failed to acquire bus name'); + this._cleanup(); + return; + } + + console.log('MPRIS: Service registered successfully'); + this._emitPlayerPropertiesChanged(['PlaybackStatus', 'Metadata', 'CanPlay', 'CanPause', 'Volume']); + } catch (error) { + console.error('MPRIS: Registration failed:', error); + this._cleanup(); + } + } + + _getPlaybackStatus() { + if (!this._playbackManager) return 'Stopped'; + switch (this._playbackManager.playbackState) { + case 'playing': return 'Playing'; + case 'paused': return 'Paused'; + default: return 'Stopped'; + } + } + + _getMetadata() { + if (this._playbackManager) { + const metadata = this._playbackManager.getMPRISMetadata(); + if (!metadata['mpris:trackid']) { + metadata['mpris:trackid'] = new GLib.Variant('o', '/org/mpris/MediaPlayer2/NoTrack'); + } + return metadata; + } + + return { + 'xesam:title': new GLib.Variant('s', 'Yet Another Radio'), + }; + } + + _getVolume() { + if (!this._settings) return 1.0; + return Math.max(0.0, Math.min(1.0, this._settings.get_int('volume') / 100.0)); + } + + _canNavigate() { + if (!this._playbackManager?.nowPlaying) return false; + return this._stationCount > 1; + } + + setStationCount(count) { + this._stationCount = count; + this._emitPlayerPropertiesChanged(['CanGoNext', 'CanGoPrevious']); + } + + _emitPropertiesChanged(interfaceName, changedProps) { + if (!this._dbusConnection) return; + + try { + this._dbusConnection.emit_signal( + null, + MPRIS_OBJECT_PATH, + 'org.freedesktop.DBus.Properties', + 'PropertiesChanged', + GLib.Variant.new_tuple([ + new GLib.Variant('s', interfaceName), + new GLib.Variant('a{sv}', changedProps), + new GLib.Variant('as', []) + ]) + ); + } catch (error) { + console.error('MPRIS: Failed to emit PropertiesChanged:', error); + } + } + + _emitPlayerPropertiesChanged(propertyNames) { + const getters = { + PlaybackStatus: () => new GLib.Variant('s', this._getPlaybackStatus()), + Metadata: () => new GLib.Variant('a{sv}', this._getMetadata()), + Volume: () => new GLib.Variant('d', this._getVolume()), + CanPlay: () => new GLib.Variant('b', !!this._playbackManager?.nowPlaying), + CanPause: () => new GLib.Variant('b', !!this._playbackManager?.nowPlaying && this._getPlaybackStatus() !== 'Stopped'), + CanGoNext: () => new GLib.Variant('b', this._canNavigate()), + CanGoPrevious: () => new GLib.Variant('b', this._canNavigate()), + }; + + const changed = {}; + for (const name of propertyNames) { + if (getters[name]) changed[name] = getters[name](); + } + + if (Object.keys(changed).length > 0) { + this._emitPropertiesChanged('org.mpris.MediaPlayer2.Player', changed); + } + } + + _cleanup() { + if (this._playbackManager) { + this._playbackManager.removeListener('onStateChanged', this._onState); + this._playbackManager.removeListener('onMetadataUpdate', this._onMeta); + this._playbackManager.removeListener('onStationChanged', this._onStation); + } + this._onState = null; + this._onMeta = null; + this._onStation = null; + + if (this._settingsChangedId !== 0) { + if (this._settings) { + this._settings.disconnect(this._settingsChangedId); + } + this._settingsChangedId = 0; + } + + if (this._ownerId !== 0) { + Gio.bus_unown_name(this._ownerId); + this._ownerId = 0; + } + + if (this._rootExported) { + this._rootExported.unexport(); + this._rootExported = null; + } + + if (this._playerExported) { + this._playerExported.unexport(); + this._playerExported = null; + } + + this._dbusConnection = null; + } + + destroy() { + this._cleanup(); + this._playbackManager = null; + this._settings = null; + } +} diff --git a/yetanotherradio@io.github.buddysirjava/modules/playbackManager.js b/yetanotherradio@io.github.buddysirjava/modules/playbackManager.js index 911da2d..d7931a8 100644 --- a/yetanotherradio@io.github.buddysirjava/modules/playbackManager.js +++ b/yetanotherradio@io.github.buddysirjava/modules/playbackManager.js @@ -16,6 +16,7 @@ export default class PlaybackManager { constructor(settings, callbacks, osdIcon = null) { this._settings = settings; this._callbacks = callbacks || {}; + this._listeners = {}; this._osdIcon = osdIcon; this._player = null; @@ -39,6 +40,23 @@ export default class PlaybackManager { }; } + addListener(event, fn) { + (this._listeners[event] ||= []).push(fn); + } + + removeListener(event, fn) { + const list = this._listeners[event]; + if (list) { + const idx = list.indexOf(fn); + if (idx !== -1) list.splice(idx, 1); + } + } + + _emit(event, ...args) { + this._callbacks[event]?.(...args); + (this._listeners[event] || []).forEach(fn => fn(...args)); + } + _initGst() { if (!Gst.is_initialized()) { Gst.init(null); @@ -57,6 +75,27 @@ export default class PlaybackManager { return this._nowPlaying; } + getMPRISMetadata() { + const metadata = {}; + + if (this._currentMetadata.title) { + metadata['xesam:title'] = new GLib.Variant('s', this._currentMetadata.title); + } + if (this._currentMetadata.artist) { + metadata['xesam:artist'] = new GLib.Variant('as', [this._currentMetadata.artist]); + } + + let artUrl = this._currentMetadata.albumArt || this._nowPlaying?.favicon || null; + if (artUrl) { + if (artUrl.startsWith('/')) { + artUrl = 'file://' + artUrl; + } + metadata['mpris:artUrl'] = new GLib.Variant('s', artUrl); + } + + return metadata; + } + _ensurePlayer() { if (this._player) return; @@ -88,9 +127,7 @@ export default class PlaybackManager { if (metadata.albumArt) this._currentMetadata.albumArt = metadata.albumArt; if (metadata.bitrate) this._currentMetadata.bitrate = metadata.bitrate; - if (this._callbacks.onMetadataUpdate) { - this._callbacks.onMetadataUpdate(); - } + this._emit('onMetadataUpdate'); } } else if (message.type === Gst.MessageType.ERROR) { const [error, debug] = message.parse_error(); @@ -159,9 +196,9 @@ export default class PlaybackManager { this._currentMetadata.nowPlaying = station; this._currentMetadata.playbackState = 'playing'; - if (this._callbacks.onStateChanged) this._callbacks.onStateChanged('playing'); - if (this._callbacks.onStationChanged) this._callbacks.onStationChanged(station); - if (this._callbacks.onVisibilityChanged) this._callbacks.onVisibilityChanged(true); + this._emit('onStateChanged', 'playing'); + this._emit('onStationChanged', station); + this._emit('onVisibilityChanged', true); this._startMetadataUpdate(); @@ -209,7 +246,7 @@ export default class PlaybackManager { this._pausedAt = Date.now(); this._currentMetadata.playbackState = 'paused'; - if (this._callbacks.onStateChanged) this._callbacks.onStateChanged('paused'); + this._emit('onStateChanged', 'paused'); } else if (this._playbackState === 'paused') { const pauseDuration = this._pausedAt ? Date.now() - this._pausedAt : 0; @@ -222,7 +259,7 @@ export default class PlaybackManager { this._playbackState = 'playing'; this._currentMetadata.playbackState = 'playing'; - if (this._callbacks.onStateChanged) this._callbacks.onStateChanged('playing'); + this._emit('onStateChanged', 'playing'); } this._pausedAt = null; } @@ -246,9 +283,9 @@ export default class PlaybackManager { this._stopMetadataUpdate(); - if (this._callbacks.onStateChanged) this._callbacks.onStateChanged('stopped'); - if (this._callbacks.onStationChanged) this._callbacks.onStationChanged(null); - if (this._callbacks.onVisibilityChanged) this._callbacks.onVisibilityChanged(false); + this._emit('onStateChanged', 'stopped'); + this._emit('onStationChanged', null); + this._emit('onVisibilityChanged', false); } setVolume(volume) { @@ -265,9 +302,7 @@ export default class PlaybackManager { interval, () => { queryPlayerTags(this._player, this._currentMetadata); - if (this._callbacks.onMetadataUpdate) { - this._callbacks.onMetadataUpdate(); - } + this._emit('onMetadataUpdate'); return true; } ); diff --git a/yetanotherradio@io.github.buddysirjava/prefs.js b/yetanotherradio@io.github.buddysirjava/prefs.js index aaf8cca..68df683 100644 --- a/yetanotherradio@io.github.buddysirjava/prefs.js +++ b/yetanotherradio@io.github.buddysirjava/prefs.js @@ -712,22 +712,6 @@ const GeneralSettingsPage = GObject.registerClass( }); this.add(generalGroup); - const mediaKeysRow = new Adw.ActionRow({ - title: _('Enable Media Keys'), - subtitle: _('Use keyboard media keys (Play/Pause, Stop) to control playback'), - }); - mediaKeysRow.set_activatable(false); - - const mediaKeysSwitch = new Gtk.Switch({ - active: this._settings.get_boolean('enable-media-keys'), - valign: 3, - }); - mediaKeysSwitch.connect('notify::active', (sw) => { - this._settings.set_boolean('enable-media-keys', sw.active); - }); - mediaKeysRow.add_suffix(mediaKeysSwitch); - generalGroup.add(mediaKeysRow); - const playingNotificationRow = new Adw.ActionRow({ title: _('Show Playing Notification'), subtitle: _('Show an on-screen notification when starting playback'), @@ -760,6 +744,22 @@ const GeneralSettingsPage = GObject.registerClass( autoPlayRow.add_suffix(autoPlaySwitch); generalGroup.add(autoPlayRow); + const mprisRow = new Adw.ActionRow({ + title: _('MPRIS Integration'), + subtitle: _('Expose the player over D-Bus so media keys and system controls work'), + }); + mprisRow.set_activatable(false); + + const mprisSwitch = new Gtk.Switch({ + active: this._settings.get_boolean('enable-mpris'), + valign: 3, + }); + mprisSwitch.connect('notify::active', (sw) => { + this._settings.set_boolean('enable-mpris', sw.active); + }); + mprisRow.add_suffix(mprisSwitch); + generalGroup.add(mprisRow); + const importExportGroup = new Adw.PreferencesGroup({ title: _('Import / Export'), description: _('Backup or restore your station list.'), diff --git a/yetanotherradio@io.github.buddysirjava/schemas/gschemas.compiled b/yetanotherradio@io.github.buddysirjava/schemas/gschemas.compiled index 5ca108d..0036f5d 100644 Binary files a/yetanotherradio@io.github.buddysirjava/schemas/gschemas.compiled and b/yetanotherradio@io.github.buddysirjava/schemas/gschemas.compiled differ diff --git a/yetanotherradio@io.github.buddysirjava/schemas/org.gnome.shell.extensions.yetanotherradio.gschema.xml b/yetanotherradio@io.github.buddysirjava/schemas/org.gnome.shell.extensions.yetanotherradio.gschema.xml index de5fce7..a6ba3e3 100644 --- a/yetanotherradio@io.github.buddysirjava/schemas/org.gnome.shell.extensions.yetanotherradio.gschema.xml +++ b/yetanotherradio@io.github.buddysirjava/schemas/org.gnome.shell.extensions.yetanotherradio.gschema.xml @@ -27,18 +27,17 @@ Auto-play last station Automatically play the last played station when the extension is enabled - - - true - Enable media keys - Enable global keyboard media keys (Play/Pause, Stop) to control playback - - true Show playing notification Show an on-screen notification when starting radio playback + + + true + Enable MPRIS integration + Expose the player over D-Bus via the MPRIS2 protocol + 100